MpdBuilder should not automatically add ContentProtection elements
- ContentProtection elements should be added explicitly by the AddContentProtection() methods. - This is because some MPDs should have ContentProtection at AdaptationSet level instead of Representation. - Change SimpleMpdNotifier, which uses MpdBuilder, to add ContentProtectionElements. The logic is moved from MuxerListener. - Add Element class for specifying subelements for ContentProtectionElement. Change-Id: I9bedfb3e5a5ac0b3d5c702f1e6e4a8608c978d1d
This commit is contained in:
parent
f492cccc1d
commit
60c54975d7
|
@ -65,18 +65,6 @@ void MpdNotifyMuxerListener::OnMediaStart(
|
|||
default_key_id_, pssh_, media_info.get());
|
||||
}
|
||||
|
||||
if (is_encrypted_) {
|
||||
// TODO(rkuroiwa): When MediaInfo's content protection fields are processed
|
||||
// in MpdBuilder (e.g. content_protection_uuid, default_key_id) then skip
|
||||
// this step if scheme_id_uri_'s UUID == content_protection_uuid_.
|
||||
// Also consider removing SetContentProtectionSchemeIdUri().
|
||||
if (!internal::AddContentProtectionElements(
|
||||
container_type, scheme_id_uri_, media_info.get())) {
|
||||
LOG(ERROR) << "Failed to add content protection elements.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mpd_notifier_->dash_profile() == kLiveProfile) {
|
||||
// TODO(kqyang): Check return result.
|
||||
mpd_notifier_->NotifyNewContainer(*media_info, ¬ification_id_);
|
||||
|
|
|
@ -201,44 +201,6 @@ bool SetVodInformation(bool has_init_range,
|
|||
return true;
|
||||
}
|
||||
|
||||
// TODO(rkuroiwa): Move this logic to MpdBuilder? MuxerListener probably doesn't
|
||||
// need to know this.
|
||||
bool AddContentProtectionElements(MuxerListener::ContainerType container_type,
|
||||
const std::string& user_scheme_id_uri,
|
||||
MediaInfo* media_info) {
|
||||
DCHECK(media_info);
|
||||
|
||||
const char kEncryptedMp4Uri[] = "urn:mpeg:dash:mp4protection:2011";
|
||||
const char kEncryptedMp4Value[] = "cenc";
|
||||
|
||||
// DASH MPD spec specifies a default ContentProtection element for ISO BMFF
|
||||
// (MP4) files.
|
||||
const bool is_mp4_container = container_type == MuxerListener::kContainerMp4;
|
||||
if (is_mp4_container) {
|
||||
MediaInfo::ContentProtectionXml* mp4_protection =
|
||||
media_info->add_content_protections();
|
||||
mp4_protection->set_scheme_id_uri(kEncryptedMp4Uri);
|
||||
mp4_protection->set_value(kEncryptedMp4Value);
|
||||
}
|
||||
|
||||
if (!user_scheme_id_uri.empty()) {
|
||||
MediaInfo::ContentProtectionXml* content_protection =
|
||||
media_info->add_content_protections();
|
||||
content_protection->set_scheme_id_uri(user_scheme_id_uri);
|
||||
} else if (is_mp4_container) {
|
||||
LOG(WARNING) << "schemeIdUri is not specified. Added default "
|
||||
"ContentProtection only.";
|
||||
}
|
||||
|
||||
if (media_info->content_protections_size() == 0) {
|
||||
LOG(ERROR) << "The stream is encrypted but no schemeIdUri specified for "
|
||||
"ContentProtection.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void SetContentProtectionFields(
|
||||
const std::string& content_protection_uuid,
|
||||
const std::string& content_protection_name_version,
|
||||
|
|
|
@ -45,15 +45,6 @@ bool SetVodInformation(bool has_init_range,
|
|||
uint64_t file_size,
|
||||
MediaInfo* media_info);
|
||||
|
||||
/// @param container_type specifies container type. A default ContentProtection
|
||||
/// element will be added if the container is MP4.
|
||||
/// @param user_scheme_id_uri is the user specified schemeIdUri for
|
||||
/// ContentProtection.
|
||||
/// @return true if a ContentProtectionXml is added, false otherwise.
|
||||
bool AddContentProtectionElements(MuxerListener::ContainerType container_type,
|
||||
const std::string& user_scheme_id_uri,
|
||||
MediaInfo* media_info);
|
||||
|
||||
/// @param content_protection_uuid is the UUID of the content protection
|
||||
/// in human readable form.
|
||||
/// @param content_protection_name_version is the DRM name and verion.
|
||||
|
|
|
@ -62,14 +62,6 @@ void VodMediaInfoDumpMuxerListener::OnMediaStart(
|
|||
content_protection_uuid_, content_protection_name_version_,
|
||||
default_key_id_, pssh_, media_info_.get());
|
||||
}
|
||||
|
||||
if (is_encrypted_) {
|
||||
if (!internal::AddContentProtectionElements(
|
||||
container_type, scheme_id_uri_, media_info_.get())) {
|
||||
LOG(ERROR) << "Failed to add content protection elements.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VodMediaInfoDumpMuxerListener::OnSampleDurationReady(
|
||||
|
|
|
@ -290,13 +290,6 @@ TEST_F(VodMediaInfoDumpMuxerListenerTest, EncryptedStream_Normal) {
|
|||
" pixel_width: 1\n"
|
||||
" pixel_height: 1\n"
|
||||
"}\n"
|
||||
"content_protections {\n"
|
||||
" scheme_id_uri: 'urn:mpeg:dash:mp4protection:2011'\n"
|
||||
" value: 'cenc'\n"
|
||||
"}\n"
|
||||
"content_protections {\n"
|
||||
" scheme_id_uri: 'http://foo.com/bar'\n"
|
||||
"}\n"
|
||||
"init_range {\n"
|
||||
" begin: 0\n"
|
||||
" end: 120\n"
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
#include "packager/mpd/base/content_protection_element.h"
|
||||
|
||||
namespace edash_packager {
|
||||
Element::Element() {}
|
||||
Element::~Element() {}
|
||||
ContentProtectionElement::ContentProtectionElement() {}
|
||||
ContentProtectionElement::~ContentProtectionElement() {}
|
||||
} // namespace edash_packager
|
||||
|
|
|
@ -14,9 +14,23 @@
|
|||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace edash_packager {
|
||||
|
||||
// This is any (XML) element.
|
||||
struct Element {
|
||||
Element();
|
||||
~Element();
|
||||
// Name of this element.
|
||||
std::string name;
|
||||
// attributes for this element.
|
||||
std::map<std::string, std::string> attributes;
|
||||
// Content of this element.
|
||||
std::string content;
|
||||
std::vector<Element> subelements;
|
||||
};
|
||||
|
||||
/// Structure to represent <ContentProtection> element in DASH MPD spec (ISO
|
||||
/// 23009-1:2012 MPD and Segment Formats).
|
||||
struct ContentProtectionElement {
|
||||
|
@ -29,8 +43,8 @@ struct ContentProtectionElement {
|
|||
// Other attributes for this element.
|
||||
std::map<std::string, std::string> additional_attributes;
|
||||
|
||||
// The elements that will be in this element.
|
||||
std::string subelements;
|
||||
// The subelements that will be in this element.
|
||||
std::vector<Element> subelements;
|
||||
};
|
||||
|
||||
} // namespace edash_packager
|
||||
|
|
|
@ -60,15 +60,18 @@ void AddMpdNameSpaceInfo(XmlNode* mpd) {
|
|||
DCHECK(mpd);
|
||||
|
||||
static const char kXmlNamespace[] = "urn:mpeg:DASH:schema:MPD:2011";
|
||||
mpd->SetStringAttribute("xmlns", kXmlNamespace);
|
||||
static const char kXmlNamespaceXsi[] =
|
||||
"http://www.w3.org/2001/XMLSchema-instance";
|
||||
mpd->SetStringAttribute("xmlns:xsi", kXmlNamespaceXsi);
|
||||
static const char kXmlNamespaceXlink[] = "http://www.w3.org/1999/xlink";
|
||||
mpd->SetStringAttribute("xmlns:xlink", kXmlNamespaceXlink);
|
||||
static const char kDashSchemaMpd2011[] =
|
||||
"urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd";
|
||||
static const char kCencNamespace[] = "urn:mpeg:cenc:2013";
|
||||
|
||||
mpd->SetStringAttribute("xmlns", kXmlNamespace);
|
||||
mpd->SetStringAttribute("xmlns:xsi", kXmlNamespaceXsi);
|
||||
mpd->SetStringAttribute("xmlns:xlink", kXmlNamespaceXlink);
|
||||
mpd->SetStringAttribute("xsi:schemaLocation", kDashSchemaMpd2011);
|
||||
mpd->SetStringAttribute("xmlns:cenc", kCencNamespace);
|
||||
}
|
||||
|
||||
bool IsPeriodNode(xmlNodePtr node) {
|
||||
|
@ -864,8 +867,6 @@ xml::ScopedXmlPtr<xmlNode>::type Representation::GetXml() {
|
|||
content_protection_elements_)) {
|
||||
return xml::ScopedXmlPtr<xmlNode>::type();
|
||||
}
|
||||
if (!representation.AddContentProtectionElementsFromMediaInfo(media_info_))
|
||||
return xml::ScopedXmlPtr<xmlNode>::type();
|
||||
|
||||
if (HasVODOnlyFields(media_info_) &&
|
||||
!representation.AddVODOnlyInfo(media_info_)) {
|
||||
|
|
|
@ -178,6 +178,10 @@ class AdaptationSet {
|
|||
Representation* AddRepresentation(const MediaInfo& media_info);
|
||||
|
||||
/// Add a ContenProtection element to the adaptation set.
|
||||
/// AdaptationSet does not add <ContentProtection> elements
|
||||
/// automatically to itself even if @a media_info.protected_content is
|
||||
/// populated. This is because some MPDs should have the elements at
|
||||
/// AdaptationSet level and some at Representation level.
|
||||
/// @param element contains the ContentProtection element contents.
|
||||
/// If @a element has {value, schemeIdUri} set and has
|
||||
/// {“value”, “schemeIdUri”} as key for @a additional_attributes,
|
||||
|
@ -294,6 +298,11 @@ class Representation {
|
|||
bool Init();
|
||||
|
||||
/// Add a ContenProtection element to the representation.
|
||||
/// Representation does not add <ContentProtection> elements
|
||||
/// automatically to itself even if @a media_info passed to
|
||||
/// AdaptationSet::AddRepresentation() has @a media_info.protected_content
|
||||
/// populated. This is because some MPDs should have the elements at
|
||||
/// AdaptationSet level and some at Representation level.
|
||||
/// @param element contains the ContentProtection element contents.
|
||||
/// If @a element has {value, schemeIdUri} set and has
|
||||
/// {“value”, “schemeIdUri”} as key for @a additional_attributes,
|
||||
|
|
|
@ -856,6 +856,58 @@ TEST_F(CommonMpdBuilderTest, SetSampleDuration) {
|
|||
representation.media_info_.video_info().frame_duration());
|
||||
}
|
||||
|
||||
// Verify that AdaptationSet::AddContentProtection() works.
|
||||
TEST_F(CommonMpdBuilderTest, AdaptationSetAddContentProtection) {
|
||||
const char kVideoMediaInfo1080p[] =
|
||||
"video_info {\n"
|
||||
" codec: \"avc1\"\n"
|
||||
" width: 1920\n"
|
||||
" height: 1080\n"
|
||||
" time_scale: 3000\n"
|
||||
" frame_duration: 100\n"
|
||||
"}\n"
|
||||
"container_type: 1\n";
|
||||
ContentProtectionElement content_protection;
|
||||
content_protection.scheme_id_uri = "someuri";
|
||||
content_protection.value = "some value";
|
||||
Element pssh;
|
||||
pssh.name = "cenc:pssh";
|
||||
pssh.content = "any value";
|
||||
content_protection.subelements.push_back(pssh);
|
||||
|
||||
AdaptationSet* video_adaptation_set = mpd_.AddAdaptationSet("");
|
||||
ASSERT_TRUE(video_adaptation_set);
|
||||
ASSERT_TRUE(video_adaptation_set->AddRepresentation(
|
||||
ConvertToMediaInfo(kVideoMediaInfo1080p)));
|
||||
video_adaptation_set->AddContentProtectionElement(content_protection);
|
||||
|
||||
const char kExpectedOutput[] =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<MPD xmlns=\"urn:mpeg:DASH:schema:MPD:2011\""
|
||||
" xmlns:cenc=\"urn:mpeg:cenc:2013\""
|
||||
" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\""
|
||||
" xmlns:xlink=\"http://www.w3.org/1999/xlink\""
|
||||
" xsi:schemaLocation=\"urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd\""
|
||||
" minBufferTime=\"PT2S\" type=\"static\""
|
||||
" profiles=\"urn:mpeg:dash:profile:isoff-on-demand:2011\""
|
||||
" mediaPresentationDuration=\"PT0S\">"
|
||||
" <Period>"
|
||||
" <AdaptationSet id=\"0\" contentType=\"video\" width=\"1920\""
|
||||
" height=\"1080\" frameRate=\"3000/100\">"
|
||||
" <ContentProtection schemeIdUri=\"someuri\" value=\"some value\">"
|
||||
" <cenc:pssh>any value</cenc:pssh>"
|
||||
" </ContentProtection>"
|
||||
" <Representation id=\"0\" bandwidth=\"0\" codecs=\"avc1\""
|
||||
" mimeType=\"video/mp4\" width=\"1920\" height=\"1080\""
|
||||
" frameRate=\"3000/100\"/>"
|
||||
" </AdaptationSet>"
|
||||
" </Period>"
|
||||
"</MPD>";
|
||||
std::string mpd_output;
|
||||
ASSERT_TRUE(mpd_.ToString(&mpd_output));
|
||||
EXPECT_TRUE(XmlEqual(kExpectedOutput, mpd_output));
|
||||
}
|
||||
|
||||
// Add one video check the output.
|
||||
TEST_F(StaticMpdBuilderTest, Video) {
|
||||
MediaInfo video_media_info = GetTestMediaInfo(kFileNameVideoMediaInfo1);
|
||||
|
@ -888,18 +940,78 @@ TEST_F(StaticMpdBuilderTest, VideoAndAudio) {
|
|||
|
||||
// MPD schema has strict ordering. AudioChannelConfiguration must appear before
|
||||
// ContentProtection.
|
||||
// Also test that Representation::AddContentProtection() works.
|
||||
TEST_F(StaticMpdBuilderTest, AudioChannelConfigurationWithContentProtection) {
|
||||
MediaInfo encrypted_audio_media_info =
|
||||
GetTestMediaInfo(kFileNameEncytpedAudioMediaInfo);
|
||||
const char kTestMediaInfo[] =
|
||||
"bandwidth: 195857\n"
|
||||
"audio_info {\n"
|
||||
" codec: 'mp4a.40.2'\n"
|
||||
" sampling_frequency: 44100\n"
|
||||
" time_scale: 44100\n"
|
||||
" num_channels: 2\n"
|
||||
"}\n"
|
||||
"init_range {\n"
|
||||
" begin: 0\n"
|
||||
" end: 863\n"
|
||||
"}\n"
|
||||
"index_range {\n"
|
||||
" begin: 864\n"
|
||||
" end: 931\n"
|
||||
"}\n"
|
||||
"media_file_name: 'encrypted_audio.mp4'\n"
|
||||
"media_duration_seconds: 24.009434\n"
|
||||
"reference_time_scale: 44100\n"
|
||||
"container_type: CONTAINER_MP4\n";
|
||||
|
||||
const char kExpectedOutput[] =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<MPD xmlns=\"urn:mpeg:DASH:schema:MPD:2011\""
|
||||
" xmlns:cenc=\"urn:mpeg:cenc:2013\""
|
||||
" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\""
|
||||
" xmlns:xlink=\"http://www.w3.org/1999/xlink\""
|
||||
" xsi:schemaLocation=\"urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd\""
|
||||
" minBufferTime=\"PT2S\" type=\"static\""
|
||||
" profiles=\"urn:mpeg:dash:profile:isoff-on-demand:2011\""
|
||||
" mediaPresentationDuration=\"PT24.00943374633789S\">"
|
||||
" <Period>"
|
||||
" <AdaptationSet id=\"0\" contentType=\"audio\">"
|
||||
" <Representation id=\"0\" bandwidth=\"195857\" codecs=\"mp4a.40.2\""
|
||||
" mimeType=\"audio/mp4\" audioSamplingRate=\"44100\">"
|
||||
" <AudioChannelConfiguration"
|
||||
" schemeIdUri="
|
||||
" \"urn:mpeg:dash:23003:3:audio_channel_configuration:2011\""
|
||||
" value=\"2\"/>"
|
||||
" <ContentProtection schemeIdUri=\"http://foo.com/\">"
|
||||
" <cenc:pssh>anything</cenc:pssh>"
|
||||
" </ContentProtection>"
|
||||
" <BaseURL>encrypted_audio.mp4</BaseURL>"
|
||||
" <SegmentBase indexRange=\"864-931\" timescale=\"44100\">"
|
||||
" <Initialization range=\"0-863\"/>"
|
||||
" </SegmentBase>"
|
||||
" </Representation>"
|
||||
" </AdaptationSet>"
|
||||
" </Period>"
|
||||
"</MPD>";
|
||||
|
||||
ContentProtectionElement content_protection;
|
||||
content_protection.scheme_id_uri = "http://foo.com/";
|
||||
Element pssh;
|
||||
pssh.name = "cenc:pssh";
|
||||
pssh.content = "anything";
|
||||
content_protection.subelements.push_back(pssh);
|
||||
|
||||
MediaInfo audio_media_info = ConvertToMediaInfo(kTestMediaInfo);
|
||||
AdaptationSet* audio_adaptation_set = mpd_.AddAdaptationSet("");
|
||||
ASSERT_TRUE(audio_adaptation_set);
|
||||
|
||||
Representation* audio_representation =
|
||||
audio_adaptation_set->AddRepresentation(encrypted_audio_media_info);
|
||||
audio_adaptation_set->AddRepresentation(audio_media_info);
|
||||
ASSERT_TRUE(audio_representation);
|
||||
audio_representation->AddContentProtectionElement(content_protection);
|
||||
|
||||
EXPECT_NO_FATAL_FAILURE(CheckMpd(kFileNameExpectedMpdOutputEncryptedAudio));
|
||||
std::string mpd_output;
|
||||
ASSERT_TRUE(mpd_.ToString(&mpd_output));
|
||||
EXPECT_TRUE(XmlEqual(kExpectedOutput, mpd_output));
|
||||
}
|
||||
|
||||
// Static profile requires bandwidth to be set because it has no other way to
|
||||
|
@ -939,14 +1051,19 @@ TEST_F(StaticMpdBuilderTest, WriteToFile) {
|
|||
}
|
||||
|
||||
// Check whether the attributes are set correctly for dynamic <MPD> element.
|
||||
// This test must use ASSERT_EQ for comparison because XmlEqual() cannot
|
||||
// handle namespaces correctly yet.
|
||||
TEST_F(DynamicMpdBuilderTest, CheckMpdAttributes) {
|
||||
static const char kExpectedOutput[] =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<MPD xmlns=\"urn:mpeg:DASH:schema:MPD:2011\" "
|
||||
"xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xlink=\"http://www.w3.org/1999/xlink\" "
|
||||
"xsi:schemaLocation=\"urn:mpeg:DASH:schema:MPD:2011 "
|
||||
"DASH-MPD.xsd\" minBufferTime=\"PT2S\" type=\"dynamic\" "
|
||||
"xsi:schemaLocation="
|
||||
"\"urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd\" "
|
||||
"xmlns:cenc=\"urn:mpeg:cenc:2013\" "
|
||||
"minBufferTime=\"PT2S\" "
|
||||
"type=\"dynamic\" "
|
||||
"profiles=\"urn:mpeg:dash:profile:isoff-live:2011\" "
|
||||
"availabilityStartTime=\"2011-12-25T12:30:00\">\n"
|
||||
" <Period start=\"PT0S\"/>\n"
|
||||
|
|
|
@ -6,7 +6,10 @@
|
|||
|
||||
#include "packager/mpd/base/simple_mpd_notifier.h"
|
||||
|
||||
#include "packager/base/base64.h"
|
||||
#include "packager/base/logging.h"
|
||||
#include "packager/base/strings/string_number_conversions.h"
|
||||
#include "packager/base/strings/string_util.h"
|
||||
#include "packager/media/file/file.h"
|
||||
#include "packager/mpd/base/mpd_builder.h"
|
||||
#include "packager/mpd/base/mpd_utils.h"
|
||||
|
@ -15,6 +18,116 @@ using edash_packager::media::File;
|
|||
|
||||
namespace edash_packager {
|
||||
|
||||
namespace {
|
||||
|
||||
// Coverts binary data into human readable UUID format.
|
||||
bool HexToUUID(const std::string& data, std::string* uuid_format) {
|
||||
DCHECK(uuid_format);
|
||||
const size_t kExpectedUUIDSize = 16;
|
||||
if (data.size() != kExpectedUUIDSize) {
|
||||
LOG(ERROR) << "Default key ID size is expected to be " << kExpectedUUIDSize
|
||||
<< " but is " << data.size();
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string hex_encoded =
|
||||
StringToLowerASCII(base::HexEncode(data.data(), data.size()));
|
||||
DCHECK_EQ(hex_encoded.size(), kExpectedUUIDSize * 2);
|
||||
base::StringPiece all(hex_encoded);
|
||||
// Note UUID has 5 parts separated with dashes.
|
||||
// e.g. 123e4567-e89b-12d3-a456-426655440000
|
||||
// These StringPieces have each part.
|
||||
base::StringPiece first = all.substr(0, 8);
|
||||
base::StringPiece second = all.substr(8, 4);
|
||||
base::StringPiece third = all.substr(12, 4);
|
||||
base::StringPiece fourth = all.substr(16, 4);
|
||||
base::StringPiece fifth= all.substr(20, 12);
|
||||
|
||||
// 32 hexadecimal characters with 4 hyphens.
|
||||
const size_t kHumanReadableUUIDSize = 36;
|
||||
uuid_format->reserve(kHumanReadableUUIDSize);
|
||||
first.CopyToString(uuid_format);
|
||||
uuid_format->append("-");
|
||||
second.AppendToString(uuid_format);
|
||||
uuid_format->append("-");
|
||||
third.AppendToString(uuid_format);
|
||||
uuid_format->append("-");
|
||||
fourth.AppendToString(uuid_format);
|
||||
uuid_format->append("-");
|
||||
fifth.AppendToString(uuid_format);
|
||||
return true;
|
||||
}
|
||||
|
||||
// This might be useful for DashIopCompliantMpdNotifier. If so it might make
|
||||
// sense to template this so that it accepts Representation and AdaptationSet.
|
||||
// For SimpleMpdNotifier, just put it in Representation. It should still
|
||||
// generate a valid MPD.
|
||||
void AddContentProtectionElements(const MediaInfo& media_info,
|
||||
Representation* representation) {
|
||||
DCHECK(representation);
|
||||
if (!media_info.has_protected_content())
|
||||
return;
|
||||
|
||||
const MediaInfo::ProtectedContent& protected_content =
|
||||
media_info.protected_content();
|
||||
|
||||
const char kEncryptedMp4Uri[] = "urn:mpeg:dash:mp4protection:2011";
|
||||
const char kEncryptedMp4Value[] = "cenc";
|
||||
|
||||
// DASH MPD spec specifies a default ContentProtection element for ISO BMFF
|
||||
// (MP4) files.
|
||||
const bool is_mp4_container =
|
||||
media_info.container_type() == MediaInfo::CONTAINER_MP4;
|
||||
if (is_mp4_container) {
|
||||
ContentProtectionElement mp4_content_protection;
|
||||
mp4_content_protection.scheme_id_uri = kEncryptedMp4Uri;
|
||||
mp4_content_protection.value = kEncryptedMp4Value;
|
||||
if (protected_content.has_default_key_id()) {
|
||||
std::string key_id_uuid_format;
|
||||
if (HexToUUID(protected_content.default_key_id(), &key_id_uuid_format)) {
|
||||
mp4_content_protection.additional_attributes["cenc:default_KID"] =
|
||||
key_id_uuid_format;
|
||||
} else {
|
||||
LOG(ERROR) << "Failed to convert default key ID into UUID format.";
|
||||
}
|
||||
}
|
||||
|
||||
representation->AddContentProtectionElement(mp4_content_protection);
|
||||
}
|
||||
|
||||
for (int i = 0; i < protected_content.content_protection_entry().size();
|
||||
++i) {
|
||||
const MediaInfo::ProtectedContent::ContentProtectionEntry& entry =
|
||||
protected_content.content_protection_entry(i);
|
||||
if (!entry.has_uuid()) {
|
||||
LOG(WARNING)
|
||||
<< "ContentProtectionEntry was specified but no UUID is set for "
|
||||
<< entry.name_version() << ", skipping.";
|
||||
continue;
|
||||
}
|
||||
|
||||
ContentProtectionElement drm_content_protection;
|
||||
drm_content_protection.scheme_id_uri = "urn:uuid:" + entry.uuid();
|
||||
if (entry.has_name_version())
|
||||
drm_content_protection.value = entry.name_version();
|
||||
|
||||
if (entry.has_pssh()) {
|
||||
std::string base64_encoded_pssh;
|
||||
base::Base64Encode(entry.pssh(), &base64_encoded_pssh);
|
||||
Element cenc_pssh;
|
||||
cenc_pssh.name = "cenc:pssh";
|
||||
cenc_pssh.content = base64_encoded_pssh;
|
||||
drm_content_protection.subelements.push_back(cenc_pssh);
|
||||
}
|
||||
|
||||
representation->AddContentProtectionElement(drm_content_protection);
|
||||
}
|
||||
|
||||
LOG_IF(WARNING, protected_content.content_protection_entry().size() == 0)
|
||||
<< "The media is encrypted but no content protection specified.";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
SimpleMpdNotifier::SimpleMpdNotifier(DashProfile dash_profile,
|
||||
const MpdOptions& mpd_options,
|
||||
const std::vector<std::string>& base_urls,
|
||||
|
@ -64,6 +177,7 @@ bool SimpleMpdNotifier::NotifyNewContainer(const MediaInfo& media_info,
|
|||
if (representation == NULL)
|
||||
return false;
|
||||
|
||||
AddContentProtectionElements(media_info, representation);
|
||||
*container_id = representation->id();
|
||||
|
||||
if (mpd_builder_->type() == MpdBuilder::kStatic)
|
||||
|
|
|
@ -33,132 +33,6 @@ std::string RangeToString(const Range& range) {
|
|||
base::Uint64ToString(range.end());
|
||||
}
|
||||
|
||||
bool SetAttributes(const google::protobuf::RepeatedPtrField<
|
||||
AttributeNameValuePair>& attributes,
|
||||
XmlNode* xml_node) {
|
||||
DCHECK(xml_node);
|
||||
for (int i = 0; i < attributes.size(); ++i) {
|
||||
const AttributeNameValuePair& attribute = attributes.Get(i);
|
||||
const std::string& name = attribute.name();
|
||||
const std::string& value = attribute.value();
|
||||
|
||||
if (name.empty()) {
|
||||
LOG(ERROR) << "For element "
|
||||
<< reinterpret_cast<const char*>(xml_node->GetRawPtr()->name)
|
||||
<< ", no name specified for attribute with value: " << value;
|
||||
return false;
|
||||
}
|
||||
|
||||
xml_node->SetStringAttribute(name.c_str(), value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// This function is recursive. Note that elements.size() == 0 is a terminating
|
||||
// condition.
|
||||
bool AddSubelements(const google::protobuf::RepeatedPtrField<
|
||||
ContentProtectionXml::Element>& elements,
|
||||
XmlNode* xml_node) {
|
||||
DCHECK(xml_node);
|
||||
for (int i = 0; i < elements.size(); ++i) {
|
||||
const ContentProtectionXml::Element& subelement = elements.Get(i);
|
||||
const std::string& subelement_name = subelement.name();
|
||||
if (subelement_name.empty()) {
|
||||
LOG(ERROR) << "Subelement name was not specified for node "
|
||||
<< reinterpret_cast<const char*>(xml_node->GetRawPtr()->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
XmlNode subelement_xml_node(subelement_name.c_str());
|
||||
if (!SetAttributes(subelement.attributes(), &subelement_xml_node)) {
|
||||
LOG(ERROR) << "Failed to set attributes for " << subelement_name;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!AddSubelements(subelement.subelements(), &subelement_xml_node)) {
|
||||
LOG(ERROR) << "Failed to add subelements to " << subelement_name;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!xml_node->AddChild(subelement_xml_node.PassScopedPtr())) {
|
||||
LOG(ERROR) << "Failed to add subelement " << subelement_name << " to "
|
||||
<< reinterpret_cast<const char*>(xml_node->GetRawPtr()->name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if 'schemeIdUri' is set in |content_protection_xml| and sets
|
||||
// |scheme_id_uri_output|. This function checks
|
||||
// ContentProtectionXml::scheme_id_uri before searching thru attributes.
|
||||
bool GetSchemeIdAttribute(const ContentProtectionXml& content_protection_xml,
|
||||
std::string* scheme_id_uri_output) {
|
||||
// Common case where 'schemeIdUri' is set directly.
|
||||
if (content_protection_xml.has_scheme_id_uri()) {
|
||||
scheme_id_uri_output->assign(content_protection_xml.scheme_id_uri());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 'schemeIdUri' is one of the attributes.
|
||||
for (int i = 0; i < content_protection_xml.attributes().size(); ++i) {
|
||||
const AttributeNameValuePair& attribute =
|
||||
content_protection_xml.attributes(i);
|
||||
const std::string& name = attribute.name();
|
||||
const std::string& value = attribute.value();
|
||||
if (name == "schemeIdUri") {
|
||||
if (value.empty())
|
||||
LOG(WARNING) << "schemeIdUri is specified with an empty string.";
|
||||
|
||||
// 'schemeIdUri' is a mandatory field but MPD doesn't care what the actual
|
||||
// value is, proceed.
|
||||
scheme_id_uri_output->assign(value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Translates ContentProtectionXml to XmlNode.
|
||||
// content_protection_xml.scheme_id_uri and content_protection_xml.value takes
|
||||
// precedence over attributes in content_protection_xml.attributes.
|
||||
bool TranslateToContentProtectionXmlNode(
|
||||
const ContentProtectionXml& content_protection_xml,
|
||||
XmlNode* xml_node_content_protection) {
|
||||
std::string scheme_id_uri;
|
||||
if (!GetSchemeIdAttribute(content_protection_xml, &scheme_id_uri)) {
|
||||
LOG(ERROR) << "ContentProtection element requires schemeIdUri.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SetAttributes(content_protection_xml.attributes(),
|
||||
xml_node_content_protection)) {
|
||||
LOG(ERROR) << "Failed to set attributes for ContentProtection.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!AddSubelements(content_protection_xml.subelements(),
|
||||
xml_node_content_protection)) {
|
||||
LOG(ERROR) << "Failed to add sublements to ContentProtection.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add 'schemeIdUri' and 'value' attributes after SetAttributes() to avoid
|
||||
// being overridden by content_protection_xml.attributes().
|
||||
xml_node_content_protection->SetStringAttribute("schemeIdUri", scheme_id_uri);
|
||||
|
||||
if (content_protection_xml.has_value()) {
|
||||
// Note that |value| is an optional field.
|
||||
xml_node_content_protection->SetStringAttribute(
|
||||
"value", content_protection_xml.value());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PopulateSegmentTimeline(const std::list<SegmentInfo>& segment_infos,
|
||||
XmlNode* segment_timeline) {
|
||||
for (std::list<SegmentInfo>::const_iterator it = segment_infos.begin();
|
||||
|
@ -199,6 +73,36 @@ bool XmlNode::AddChild(ScopedXmlPtr<xmlNode>::type child) {
|
|||
return true;
|
||||
}
|
||||
|
||||
bool XmlNode::AddElements(const std::vector<Element>& elements) {
|
||||
for (size_t element_index = 0; element_index < elements.size();
|
||||
++element_index) {
|
||||
const Element& child_element = elements[element_index];
|
||||
XmlNode child_node(child_element.name.c_str());
|
||||
for (std::map<std::string, std::string>::const_iterator attribute_it =
|
||||
child_element.attributes.begin();
|
||||
attribute_it != child_element.attributes.end(); ++attribute_it) {
|
||||
child_node.SetStringAttribute(attribute_it->first.c_str(),
|
||||
attribute_it->second);
|
||||
}
|
||||
// Recursively set children for the child.
|
||||
if (!child_node.AddElements(child_element.subelements))
|
||||
return false;
|
||||
|
||||
child_node.SetContent(child_element.content);
|
||||
|
||||
if (!xmlAddChild(node_.get(), child_node.GetRawPtr())) {
|
||||
LOG(ERROR) << "Failed to set child " << child_element.name
|
||||
<< " to parent element "
|
||||
<< reinterpret_cast<const char*>(node_->name);
|
||||
return false;
|
||||
}
|
||||
// Reaching here means the ownership of |child_node| transfered to |node_|.
|
||||
// Release the pointer so that it doesn't get destructed in this scope.
|
||||
ignore_result(child_node.Release());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void XmlNode::SetStringAttribute(const char* attribute_name,
|
||||
const std::string& attribute) {
|
||||
DCHECK(node_);
|
||||
|
@ -265,39 +169,15 @@ bool RepresentationBaseXmlNode::AddContentProtectionElements(
|
|||
return true;
|
||||
}
|
||||
|
||||
bool RepresentationBaseXmlNode::AddContentProtectionElementsFromMediaInfo(
|
||||
const MediaInfo& media_info) {
|
||||
const bool has_content_protections =
|
||||
media_info.content_protections().size() > 0;
|
||||
|
||||
if (!has_content_protections)
|
||||
return true;
|
||||
|
||||
for (int i = 0; i < media_info.content_protections().size(); ++i) {
|
||||
const ContentProtectionXml& content_protection_xml =
|
||||
media_info.content_protections(i);
|
||||
XmlNode content_protection_node("ContentProtection");
|
||||
if (!TranslateToContentProtectionXmlNode(content_protection_xml,
|
||||
&content_protection_node)) {
|
||||
LOG(ERROR) << "Failed to make ContentProtection element from MediaInfo.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!AddChild(content_protection_node.PassScopedPtr())) {
|
||||
LOG(ERROR) << "Failed to add ContentProtection to Representation.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RepresentationBaseXmlNode::AddContentProtectionElement(
|
||||
const ContentProtectionElement& content_protection_element) {
|
||||
XmlNode content_protection_node("ContentProtection");
|
||||
|
||||
content_protection_node.SetStringAttribute("value",
|
||||
content_protection_element.value);
|
||||
// @value is an optional attribute.
|
||||
if (!content_protection_element.value.empty()) {
|
||||
content_protection_node.SetStringAttribute(
|
||||
"value", content_protection_element.value);
|
||||
}
|
||||
content_protection_node.SetStringAttribute(
|
||||
"schemeIdUri", content_protection_element.scheme_id_uri);
|
||||
|
||||
|
@ -312,7 +192,10 @@ bool RepresentationBaseXmlNode::AddContentProtectionElement(
|
|||
attributes_it->second);
|
||||
}
|
||||
|
||||
content_protection_node.SetContent(content_protection_element.subelements);
|
||||
if (!content_protection_node.AddElements(
|
||||
content_protection_element.subelements)) {
|
||||
return false;
|
||||
}
|
||||
return AddChild(content_protection_node.PassScopedPtr());
|
||||
}
|
||||
|
||||
|
|
|
@ -41,6 +41,9 @@ class XmlNode {
|
|||
/// @return true on success, false otherwise.
|
||||
bool AddChild(ScopedXmlPtr<xmlNode>::type child);
|
||||
|
||||
/// Adds Elements to this node using the Element struct.
|
||||
bool AddElements(const std::vector<Element>& elements);
|
||||
|
||||
/// Set a string attribute.
|
||||
/// @param attribute_name The name (lhs) of the attribute.
|
||||
/// @param attribute The value (rhs) of the attribute.
|
||||
|
@ -62,8 +65,9 @@ class XmlNode {
|
|||
void SetId(uint32_t id);
|
||||
|
||||
/// Set the contents of an XML element using a string.
|
||||
/// Note: This function does not work well with AddChild(). Use either
|
||||
/// AddChild() or SetContent() when setting the content of this node.
|
||||
/// This cannot set child elements because <> will become < and &rt;
|
||||
/// This should be used to set the text for the element, e.g. setting
|
||||
/// a URL for <BaseURL> element.
|
||||
/// @param content is a string containing the text-encoded child elements to
|
||||
/// be added to the element.
|
||||
void SetContent(const std::string& content);
|
||||
|
@ -95,12 +99,6 @@ class RepresentationBaseXmlNode : public XmlNode {
|
|||
bool AddContentProtectionElements(
|
||||
const std::list<ContentProtectionElement>& content_protection_elements);
|
||||
|
||||
/// Add a ContentProtection elements to this element.
|
||||
/// @param media_info is a MediaInfo containing the ContentProtection
|
||||
/// elements to add.
|
||||
/// @return true on success, false otherwise.
|
||||
bool AddContentProtectionElementsFromMediaInfo(const MediaInfo& media_info);
|
||||
|
||||
protected:
|
||||
explicit RepresentationBaseXmlNode(const char* name);
|
||||
|
||||
|
|
|
@ -84,7 +84,7 @@ class RepresentationTest : public ::testing::Test {
|
|||
// Make sure XmlEqual() is functioning correctly.
|
||||
// TODO(rkuroiwa): Move this to a separate file. This requires it to be TEST_F
|
||||
// due to gtest /test
|
||||
TEST_F(RepresentationTest, MetaTest_XmlEqual) {
|
||||
TEST_F(RepresentationTest, MetaTestXmlElementsEqual) {
|
||||
static const char kXml1[] =
|
||||
"<A>\n"
|
||||
" <B\n"
|
||||
|
@ -167,37 +167,54 @@ TEST_F(RepresentationTest, MetaTest_XmlEqual) {
|
|||
ASSERT_FALSE(XmlEqual(kXml1AttributeReorder, kXml1ChildrenReordered));
|
||||
}
|
||||
|
||||
TEST_F(RepresentationTest, AddContentProtectionXml) {
|
||||
static const char kExpectedRepresentaionString[] =
|
||||
// Verify that if contents are different, XmlEqual returns false.
|
||||
// This is to catch the case where just using xmlNodeGetContent() on elements
|
||||
// that have subelements don't quite work well.
|
||||
// xmlNodeGetContent(<A>) (for both <A>s) will return "content1content2".
|
||||
// But if it is run on <B> for the first XML, it will return "content1", but
|
||||
// for second XML will return "c".
|
||||
TEST_F(RepresentationTest, MetaTestXmlEqualDifferentContent) {
|
||||
ASSERT_FALSE(XmlEqual(
|
||||
"<A><B>content1</B><B>content2</B></A>",
|
||||
"<A><B>c</B><B>ontent1content2</B></A>"));
|
||||
}
|
||||
|
||||
// Verify that AddContentProtectionElements work.
|
||||
// xmlReadMemory() (used in XmlEqual()) doesn't like XML fragments that have
|
||||
// namespaces without context, e.g. <cenc:pssh> element.
|
||||
// The MpdBuilderTests work because the MPD element has xmlns:cenc attribute.
|
||||
// Tests that have <cenc:pssh> is in mpd_builder_unittest.
|
||||
TEST_F(RepresentationTest, AddContentProtectionElements) {
|
||||
std::list<ContentProtectionElement> content_protections;
|
||||
ContentProtectionElement content_protection_widevine;
|
||||
content_protection_widevine.scheme_id_uri =
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed";
|
||||
content_protection_widevine.value = "SOME bogus Widevine DRM version";
|
||||
Element any_element;
|
||||
any_element.name = "AnyElement";
|
||||
any_element.content = "any content";
|
||||
content_protection_widevine.subelements.push_back(any_element);
|
||||
content_protections.push_back(content_protection_widevine);
|
||||
|
||||
ContentProtectionElement content_protection_clearkey;
|
||||
content_protection_clearkey.scheme_id_uri =
|
||||
"urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b";
|
||||
content_protections.push_back(content_protection_clearkey);
|
||||
|
||||
representation_.AddContentProtectionElements(content_protections);
|
||||
ScopedXmlPtr<xmlDoc>::type doc(MakeDoc(representation_.PassScopedPtr()));
|
||||
ASSERT_TRUE(XmlEqual(
|
||||
"<Representation>\n"
|
||||
" <ContentProtection\n"
|
||||
" a=\"1\"\n"
|
||||
" b=\"2\"\n"
|
||||
" schemeIdUri=\"http://www.foo.com/drm\"\n"
|
||||
" value=\"somevalue\">\n"
|
||||
" <TestSubElement c=\"3\" d=\"4\"/>\n"
|
||||
" schemeIdUri=\"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed\"\n"
|
||||
" value=\"SOME bogus Widevine DRM version\">\n"
|
||||
" <AnyElement>any content</AnyElement>\n"
|
||||
" </ContentProtection>\n"
|
||||
"</Representation>";
|
||||
|
||||
MediaInfo media_info;
|
||||
MediaInfo::ContentProtectionXml* content_protection_xml =
|
||||
media_info.add_content_protections();
|
||||
content_protection_xml->set_scheme_id_uri("http://www.foo.com/drm");
|
||||
content_protection_xml->set_value("somevalue");
|
||||
AddAttribute("a", "1", content_protection_xml);
|
||||
AddAttribute("b", "2", content_protection_xml);
|
||||
|
||||
MediaInfo::ContentProtectionXml::Element* subelement =
|
||||
content_protection_xml->add_subelements();
|
||||
subelement->set_name("TestSubElement");
|
||||
AddAttribute("c", "3", subelement);
|
||||
AddAttribute("d", "4", subelement);
|
||||
|
||||
ASSERT_TRUE(
|
||||
representation_.AddContentProtectionElementsFromMediaInfo(media_info));
|
||||
ScopedXmlPtr<xmlDoc>::type doc(MakeDoc(representation_.PassScopedPtr()));
|
||||
ASSERT_TRUE(
|
||||
XmlEqual(kExpectedRepresentaionString, doc.get()));
|
||||
" <ContentProtection\n"
|
||||
" schemeIdUri=\"urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b\">"
|
||||
" </ContentProtection>\n"
|
||||
"</Representation>",
|
||||
doc.get()));
|
||||
}
|
||||
|
||||
// Some template names cannot be used for init segment name.
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
<AdaptationSet id="0" contentType="audio">
|
||||
<Representation id="0" bandwidth="195857" codecs="mp4a.40.2" mimeType="audio/mp4" audioSamplingRate="44100">
|
||||
<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"/>
|
||||
<ContentProtection schemeIdUri="http://foo.com/"/>
|
||||
<BaseURL>encrypted_audio.mp4</BaseURL>
|
||||
<SegmentBase indexRange="864-931" timescale="44100">
|
||||
<Initialization range="0-863"/>
|
||||
|
|
|
@ -8,15 +8,16 @@
|
|||
#include <utility>
|
||||
|
||||
#include "packager/base/logging.h"
|
||||
#include "packager/base/strings/string_util.h"
|
||||
#include "packager/mpd/base/xml/scoped_xml_ptr.h"
|
||||
#include "packager/third_party/libxml/src/include/libxml/parser.h"
|
||||
|
||||
namespace edash_packager {
|
||||
|
||||
namespace {
|
||||
xml::ScopedXmlPtr<xmlDoc>::type GetDocFromString(const std::string& xml_str) {
|
||||
xml::ScopedXmlPtr<xmlDoc>::type schema_as_doc(
|
||||
xmlReadMemory(xml_str.data(), xml_str.size(), NULL, NULL, 0));
|
||||
|
||||
xml::ScopedXmlPtr<xmlDoc>::type schema_as_doc(xmlReadMemory(
|
||||
xml_str.data(), xml_str.size(), NULL, NULL, 0));
|
||||
return schema_as_doc.Pass();
|
||||
}
|
||||
|
||||
|
@ -74,12 +75,33 @@ bool CompareNames(xmlNodePtr node1, xmlNodePtr node2) {
|
|||
return xmlStrcmp(node1->name, node2->name) == 0;
|
||||
}
|
||||
|
||||
bool CompareContents(xmlNodePtr node1, xmlNodePtr node2) {
|
||||
std::string node1_content =
|
||||
reinterpret_cast<const char*>(xmlNodeGetContent(node1));
|
||||
std::string node2_content =
|
||||
reinterpret_cast<const char*>(xmlNodeGetContent(node2));
|
||||
base::ReplaceChars(node1_content, "\n", "", &node1_content);
|
||||
base::TrimString(node1_content, " ", &node1_content);
|
||||
base::ReplaceChars(node2_content, "\n", "", &node2_content);
|
||||
base::TrimString(node2_content, " ", &node2_content);
|
||||
DVLOG(2) << "Comparing contents of "
|
||||
<< reinterpret_cast<const char*>(node1->name) << "\n"
|
||||
<< "First node's content:\n" << node1_content << "\n"
|
||||
<< "Second node's content:\n" << node2_content;
|
||||
const bool same_content = node1_content == node2_content;
|
||||
LOG_IF(ERROR, !same_content)
|
||||
<< "Contents of " << reinterpret_cast<const char*>(node1->name)
|
||||
<< " do not match.\n"
|
||||
<< "First node's content:\n" << node1_content << "\n"
|
||||
<< "Second node's content:\n" << node2_content;
|
||||
return same_content;
|
||||
}
|
||||
|
||||
// Recursively check the elements.
|
||||
// Note that the terminating condition of the recursion is when the children do
|
||||
// not match (inside the loop).
|
||||
bool CompareNodes(xmlNodePtr node1, xmlNodePtr node2) {
|
||||
DCHECK(node1 && node2);
|
||||
|
||||
if (!CompareNames(node1, node2)) {
|
||||
LOG(ERROR) << "Names of the nodes do not match: "
|
||||
<< reinterpret_cast<const char*>(node1->name) << " "
|
||||
|
@ -96,6 +118,12 @@ bool CompareNodes(xmlNodePtr node1, xmlNodePtr node2) {
|
|||
|
||||
xmlNodePtr node1_child = xmlFirstElementChild(node1);
|
||||
xmlNodePtr node2_child = xmlFirstElementChild(node2);
|
||||
if (!node1_child && !node2_child) {
|
||||
// Note that xmlFirstElementChild() returns NULL if there are only
|
||||
// text type children.
|
||||
return CompareContents(node1, node2);
|
||||
}
|
||||
|
||||
do {
|
||||
if (!node1_child || !node2_child)
|
||||
return node1_child == node2_child;
|
||||
|
|
Loading…
Reference in New Issue