blob: 526f262d0dbf7db2d6468f331172ad1176d43b6d [file] [log] [blame]
/*
* Copyright (C) 2013 Google Inc. All Rights Reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// Modifications Copyright 2017 The Cobalt Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#include "cobalt/dom/media_source.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <vector>
#include "base/compiler_specific.h"
#include "base/guid.h"
#include "base/logging.h"
#include "base/single_thread_task_runner.h"
#include "base/threading/thread_task_runner_handle.h"
#include "base/trace_event/trace_event.h"
#include "cobalt/base/polymorphic_downcast.h"
#include "cobalt/base/tokens.h"
#include "cobalt/dom/dom_settings.h"
#include "cobalt/dom/media_settings.h"
#include "cobalt/web/context.h"
#include "cobalt/web/dom_exception.h"
#include "cobalt/web/event.h"
#include "starboard/media.h"
#include "third_party/chromium/media/base/pipeline_status.h"
namespace cobalt {
namespace dom {
namespace {
using ::media::CHUNK_DEMUXER_ERROR_EOS_STATUS_DECODE_ERROR;
using ::media::CHUNK_DEMUXER_ERROR_EOS_STATUS_NETWORK_ERROR;
using ::media::PIPELINE_OK;
using ::media::PipelineStatus;
const MediaSettings& GetMediaSettings(web::EnvironmentSettings* settings) {
DCHECK(settings);
DCHECK(settings->context());
DCHECK(settings->context()->web_settings());
const auto& web_settings = settings->context()->web_settings();
return web_settings->media_settings();
}
// If the system has more processors than the specified value, SourceBuffer
// append and remove algorithm will be offloaded to a non-web thread to reduce
// the load on the web thread.
// The default value is 1024, which effectively disable offloading by default.
// Setting to a reasonably low value (say 0 or 2) will enable algorithm
// offloading.
bool IsAlgorithmOffloadEnabled(web::EnvironmentSettings* settings) {
int min_process_count_to_offload =
GetMediaSettings(settings)
.GetMinimumProcessorCountToOffloadAlgorithm()
.value_or(1024);
DCHECK_GE(min_process_count_to_offload, 0);
return SbSystemGetNumberOfProcessors() >= min_process_count_to_offload;
}
// If this function returns true, SourceBuffer will reduce asynchronous
// behaviors. For example, queued events will be dispatached immediately when
// possible.
// The default value is false.
bool IsAsynchronousReductionEnabled(web::EnvironmentSettings* settings) {
return GetMediaSettings(settings).IsAsynchronousReductionEnabled().value_or(
false);
}
// If this function returns true, MediaSource::EndOfStreamAlgorithm() will call
// SetReadyState(kMediaSourceReadyStateEnded) even if MediaSource object is
// closed.
// The default value is false.
bool IsCallingEndedWhenClosedEnabled(web::EnvironmentSettings* settings) {
return GetMediaSettings(settings).IsCallingEndedWhenClosedEnabled().value_or(
false);
}
// If the size of a job that is part of an algorithm is less than or equal to
// the return value of this function, the implementation will run the job
// immediately instead of scheduling it to run later to reduce latency.
// NOTE: This is currently only enabled for buffer append.
// The default value is 0 KB, which disables immediate job completely.
int GetMaxSizeForImmediateJob(web::EnvironmentSettings* settings) {
const int kDefaultMaxSize = 0;
auto max_size =
GetMediaSettings(settings).GetMaxSizeForImmediateJob().value_or(
kDefaultMaxSize);
DCHECK_GE(max_size, 0);
return max_size;
}
} // namespace
MediaSource::MediaSource(script::EnvironmentSettings* settings)
: web::EventTarget(settings),
algorithm_offload_enabled_(
IsAlgorithmOffloadEnabled(environment_settings())),
asynchronous_reduction_enabled_(
IsAsynchronousReductionEnabled(environment_settings())),
max_size_for_immediate_job_(
GetMaxSizeForImmediateJob(environment_settings())),
default_algorithm_runner_(asynchronous_reduction_enabled_),
chunk_demuxer_(NULL),
ready_state_(kMediaSourceReadyStateClosed),
ALLOW_THIS_IN_INITIALIZER_LIST(event_queue_(this)),
source_buffers_(new SourceBufferList(settings, &event_queue_)),
active_source_buffers_(new SourceBufferList(settings, &event_queue_)),
live_seekable_range_(new TimeRanges) {
LOG(INFO) << "Algorithm offloading is "
<< (algorithm_offload_enabled_ ? "enabled" : "disabled");
LOG(INFO) << "Asynchronous reduction is "
<< (asynchronous_reduction_enabled_ ? "enabled" : "disabled");
LOG(INFO) << "Max size of immediate job is set to "
<< max_size_for_immediate_job_;
}
MediaSource::~MediaSource() { SetReadyState(kMediaSourceReadyStateClosed); }
scoped_refptr<SourceBufferList> MediaSource::source_buffers() const {
return source_buffers_;
}
scoped_refptr<SourceBufferList> MediaSource::active_source_buffers() const {
return active_source_buffers_;
}
MediaSourceReadyState MediaSource::ready_state() const { return ready_state_; }
double MediaSource::duration(script::ExceptionState* exception_state) const {
if (ready_state_ == kMediaSourceReadyStateClosed) {
return std::numeric_limits<float>::quiet_NaN();
}
DCHECK(chunk_demuxer_);
return chunk_demuxer_->GetDuration();
}
void MediaSource::set_duration(double duration,
script::ExceptionState* exception_state) {
if (duration < 0.0 || std::isnan(duration)) {
web::DOMException::Raise(web::DOMException::kIndexSizeErr, exception_state);
return;
}
if (!IsOpen() || IsUpdating()) {
web::DOMException::Raise(web::DOMException::kInvalidStateErr,
exception_state);
return;
}
// Run the duration change algorithm
if (duration == this->duration(NULL)) {
return;
}
double highest_buffered_presentation_timestamp = 0;
for (uint32 i = 0; i < source_buffers_->length(); ++i) {
highest_buffered_presentation_timestamp =
std::max(highest_buffered_presentation_timestamp,
source_buffers_->Item(i)->GetHighestPresentationTimestamp());
}
if (duration < highest_buffered_presentation_timestamp) {
web::DOMException::Raise(web::DOMException::kInvalidStateErr,
exception_state);
return;
}
// 3. Set old duration to the current value of duration.
double old_duration = this->duration(NULL);
DCHECK_LE(highest_buffered_presentation_timestamp,
std::isnan(old_duration) ? 0 : old_duration);
// 4. Update duration to new duration.
bool request_seek = attached_element_->current_time(NULL) > duration;
chunk_demuxer_->SetDuration(duration);
// 5. If a user agent is unable to partially render audio frames or text cues
// that start before and end after the duration, then run the following
// steps:
// NOTE: Currently we assume that the media engine is able to render
// partial frames/cues. If a media engine gets added that doesn't support
// this, then we'll need to add logic to handle the substeps.
// 6. Update the media controller duration to new duration and run the
// HTMLMediaElement duration change algorithm.
attached_element_->DurationChanged(duration, request_seek);
}
scoped_refptr<SourceBuffer> MediaSource::AddSourceBuffer(
script::EnvironmentSettings* settings, const std::string& type,
script::ExceptionState* exception_state) {
TRACE_EVENT1("cobalt::dom", "MediaSource::AddSourceBuffer()", "type", type);
LOG(INFO) << "add SourceBuffer with type " << type;
if (type.empty()) {
web::DOMException::Raise(web::DOMException::kInvalidAccessErr,
exception_state);
// Return value should be ignored.
return NULL;
}
if (!IsTypeSupported(settings, type)) {
web::DOMException::Raise(web::DOMException::kNotSupportedErr,
exception_state);
return NULL;
}
if (!IsOpen()) {
web::DOMException::Raise(web::DOMException::kInvalidStateErr,
exception_state);
return NULL;
}
std::string guid = base::GenerateGUID();
scoped_refptr<SourceBuffer> source_buffer;
ChunkDemuxer::Status status = chunk_demuxer_->AddId(guid, type);
switch (status) {
case ChunkDemuxer::kOk:
source_buffer =
new SourceBuffer(settings, guid, this, chunk_demuxer_, &event_queue_);
break;
case ChunkDemuxer::kNotSupported:
web::DOMException::Raise(web::DOMException::kNotSupportedErr,
exception_state);
return NULL;
case ChunkDemuxer::kReachedIdLimit:
web::DOMException::Raise(web::DOMException::kQuotaExceededErr,
exception_state);
return NULL;
}
DCHECK(source_buffer);
source_buffers_->Add(source_buffer);
return source_buffer;
}
void MediaSource::RemoveSourceBuffer(
const scoped_refptr<SourceBuffer>& source_buffer,
script::ExceptionState* exception_state) {
TRACE_EVENT0("cobalt::dom", "MediaSource::RemoveSourceBuffer()");
if (source_buffer.get() == NULL) {
web::DOMException::Raise(web::DOMException::kInvalidAccessErr,
exception_state);
return;
}
if (source_buffers_->length() == 0 ||
!source_buffers_->Contains(source_buffer)) {
web::DOMException::Raise(web::DOMException::kNotFoundErr, exception_state);
return;
}
source_buffer->OnRemovedFromMediaSource();
active_source_buffers_->Remove(source_buffer);
source_buffers_->Remove(source_buffer);
}
void MediaSource::EndOfStreamAlgorithm(MediaSourceEndOfStreamError error) {
if (IsClosed()) {
if (IsCallingEndedWhenClosedEnabled(environment_settings())) {
LOG(INFO) << "Setting state to ended when MediaSource object is closed";
// Calling the function below here leads to ANR in production, as
// EndOfStreamAlgorithm() can be called by SetReadyState().
// Calling SetReadyState() nestedly leads to re-entrance of Abort() on
// the SourceBuffer algorithm handle, where a mutex gets re-acquired.
// Keep this code path here so we have the option to revert it to the
// original behavior in production.
SetReadyState(kMediaSourceReadyStateEnded);
} else {
LOG(INFO)
<< "Skip setting state to ended when MediaSource object is closed";
}
} else {
SetReadyState(kMediaSourceReadyStateEnded);
}
PipelineStatus pipeline_status = PIPELINE_OK;
if (error == kMediaSourceEndOfStreamErrorNetwork) {
pipeline_status = CHUNK_DEMUXER_ERROR_EOS_STATUS_NETWORK_ERROR;
} else if (error == kMediaSourceEndOfStreamErrorDecode) {
pipeline_status = CHUNK_DEMUXER_ERROR_EOS_STATUS_DECODE_ERROR;
}
chunk_demuxer_->MarkEndOfStream(pipeline_status);
}
void MediaSource::EndOfStream(script::ExceptionState* exception_state) {
TRACE_EVENT0("cobalt::dom", "MediaSource::EndOfStream()");
// If there is no error string provided, treat it as empty.
EndOfStream(kMediaSourceEndOfStreamErrorNoError, exception_state);
}
void MediaSource::EndOfStream(MediaSourceEndOfStreamError error,
script::ExceptionState* exception_state) {
TRACE_EVENT1("cobalt::dom", "MediaSource::EndOfStream()", "error", error);
if (!IsOpen() || IsUpdating()) {
web::DOMException::Raise(web::DOMException::kInvalidStateErr,
exception_state);
return;
}
EndOfStreamAlgorithm(error);
}
void MediaSource::SetLiveSeekableRange(
double start, double end, script::ExceptionState* exception_state) {
TRACE_EVENT2("cobalt::dom", "MediaSource::SetLiveSeekableRange()", "start",
start, "end", end);
if (!IsOpen()) {
web::DOMException::Raise(web::DOMException::kInvalidStateErr,
exception_state);
return;
}
if (start < 0 || start > end) {
web::DOMException::Raise(web::DOMException::kIndexSizeErr, exception_state);
return;
}
live_seekable_range_ = new TimeRanges(start, end);
}
void MediaSource::ClearLiveSeekableRange(
script::ExceptionState* exception_state) {
TRACE_EVENT0("cobalt::dom", "MediaSource::ClearLiveSeekableRange()");
if (!IsOpen()) {
web::DOMException::Raise(web::DOMException::kInvalidStateErr,
exception_state);
return;
}
if (live_seekable_range_->length() != 0) {
live_seekable_range_ = new TimeRanges;
}
}
// static
bool MediaSource::IsTypeSupported(script::EnvironmentSettings* settings,
const std::string& type) {
TRACE_EVENT1("cobalt::dom", "MediaSource::IsTypeSupported()", "type", type);
DCHECK(settings);
DOMSettings* dom_settings =
base::polymorphic_downcast<DOMSettings*>(settings);
DCHECK(dom_settings->can_play_type_handler());
SbMediaSupportType support_type =
dom_settings->can_play_type_handler()->CanPlayAdaptive(type.c_str(), "");
switch (support_type) {
case kSbMediaSupportTypeNotSupported:
return false;
case kSbMediaSupportTypeMaybe:
return true;
case kSbMediaSupportTypeProbably:
return true;
default:
NOTREACHED();
return false;
}
}
bool MediaSource::AttachToElement(HTMLMediaElement* media_element) {
if (attached_element_) {
return false;
}
DCHECK(IsClosed());
DCHECK(!algorithm_process_thread_);
attached_element_ = base::AsWeakPtr(media_element);
has_max_video_capabilities_ = media_element->HasMaxVideoCapabilities();
if (algorithm_offload_enabled_) {
algorithm_process_thread_.reset(new base::Thread("MSEAlgorithm"));
if (!algorithm_process_thread_->Start()) {
LOG(WARNING) << "Starting algorithm process thread failed, disable"
" algorithm offloading";
algorithm_process_thread_.reset();
}
}
if (algorithm_process_thread_) {
LOG(INFO) << "Algorithm offloading enabled.";
offload_algorithm_runner_.reset(
new OffloadAlgorithmRunner<SourceBufferAlgorithm>(
algorithm_process_thread_->message_loop()->task_runner(),
base::ThreadTaskRunnerHandle::Get()));
} else {
LOG(INFO) << "Algorithm offloading disabled.";
}
return true;
}
void MediaSource::SetChunkDemuxerAndOpen(ChunkDemuxer* chunk_demuxer) {
DCHECK(chunk_demuxer);
DCHECK(!chunk_demuxer_);
DCHECK(attached_element_);
chunk_demuxer_ = chunk_demuxer;
SetReadyState(kMediaSourceReadyStateOpen);
}
void MediaSource::Close() { SetReadyState(kMediaSourceReadyStateClosed); }
bool MediaSource::IsClosed() const {
return ready_state_ == kMediaSourceReadyStateClosed;
}
scoped_refptr<TimeRanges> MediaSource::GetBufferedRange() const {
std::vector<scoped_refptr<TimeRanges> > ranges(
active_source_buffers_->length());
for (uint32 i = 0; i < active_source_buffers_->length(); ++i)
ranges[i] = active_source_buffers_->Item(i)->buffered(NULL);
if (ranges.empty()) {
return new TimeRanges;
}
double highest_end_time = -1;
for (size_t i = 0; i < ranges.size(); ++i) {
uint32 length = ranges[i]->length();
if (length > 0) {
highest_end_time =
std::max(highest_end_time, ranges[i]->End(length - 1, NULL));
}
}
// Return an empty range if all ranges are empty.
if (highest_end_time < 0) {
return new TimeRanges;
}
scoped_refptr<TimeRanges> intersection_ranges =
new TimeRanges(0, highest_end_time);
bool ended = ready_state() == kMediaSourceReadyStateEnded;
for (size_t i = 0; i < ranges.size(); ++i) {
scoped_refptr<TimeRanges> source_ranges = ranges[i].get();
if (ended && source_ranges->length()) {
source_ranges->Add(
source_ranges->Start(source_ranges->length() - 1, NULL),
highest_end_time);
}
intersection_ranges = intersection_ranges->IntersectWith(source_ranges);
}
return intersection_ranges;
}
scoped_refptr<TimeRanges> MediaSource::GetSeekable() const {
// Implements MediaSource algorithm for HTMLMediaElement.seekable.
double source_duration = duration(NULL);
if (std::isnan(source_duration)) {
return new TimeRanges;
}
if (source_duration == std::numeric_limits<double>::infinity()) {
scoped_refptr<TimeRanges> buffered = attached_element_->buffered();
if (live_seekable_range_->length() != 0) {
if (buffered->length() == 0) {
return new TimeRanges(live_seekable_range_->Start(0, NULL),
live_seekable_range_->End(0, NULL));
}
return new TimeRanges(
std::min(live_seekable_range_->Start(0, NULL),
buffered->Start(0, NULL)),
std::max(live_seekable_range_->End(0, NULL),
buffered->End(buffered->length() - 1, NULL)));
}
if (buffered->length() == 0) {
return new TimeRanges;
}
return new TimeRanges(0, buffered->End(buffered->length() - 1, NULL));
}
return new TimeRanges(0, source_duration);
}
void MediaSource::OnAudioTrackChanged(AudioTrack* audio_track) {
scoped_refptr<SourceBuffer> source_buffer = audio_track->source_buffer();
if (!source_buffer) {
return;
}
DCHECK(source_buffers_->Contains(source_buffer));
source_buffer->audio_tracks()->ScheduleChangeEvent();
bool is_active = (source_buffer->video_tracks()->selected_index() != -1) ||
source_buffer->audio_tracks()->HasEnabledTrack();
SetSourceBufferActive(source_buffer, is_active);
}
void MediaSource::OnVideoTrackChanged(VideoTrack* video_track) {
scoped_refptr<SourceBuffer> source_buffer = video_track->source_buffer();
if (!source_buffer) {
return;
}
DCHECK(source_buffers_->Contains(source_buffer));
if (video_track->selected()) {
source_buffer->video_tracks()->OnTrackSelected(video_track->id());
}
source_buffer->video_tracks()->ScheduleChangeEvent();
bool is_active = source_buffer->video_tracks()->selected_index() != -1 ||
source_buffer->audio_tracks()->HasEnabledTrack();
SetSourceBufferActive(source_buffer, is_active);
}
void MediaSource::OpenIfInEndedState() {
if (ready_state_ != kMediaSourceReadyStateEnded) {
return;
}
SetReadyState(kMediaSourceReadyStateOpen);
chunk_demuxer_->UnmarkEndOfStream();
}
bool MediaSource::IsOpen() const {
return ready_state_ == kMediaSourceReadyStateOpen;
}
void MediaSource::SetSourceBufferActive(SourceBuffer* source_buffer,
bool is_active) {
// We don't support deactivate a source buffer.
DCHECK(is_active);
DCHECK(source_buffers_->Contains(source_buffer));
if (!is_active) {
DCHECK(active_source_buffers_->Contains(source_buffer));
active_source_buffers_->Remove(source_buffer);
return;
}
if (active_source_buffers_->Contains(source_buffer)) {
return;
}
size_t index = source_buffers_->Find(source_buffer);
uint32 insert_position = 0;
while (insert_position < active_source_buffers_->length() &&
source_buffers_->Find(active_source_buffers_->Item(insert_position)) <
index) {
++insert_position;
}
active_source_buffers_->Insert(insert_position, source_buffer);
}
HTMLMediaElement* MediaSource::GetMediaElement() const {
return attached_element_;
}
bool MediaSource::MediaElementHasMaxVideoCapabilities() const {
SB_DCHECK(attached_element_);
return has_max_video_capabilities_;
}
SerializedAlgorithmRunner<SourceBufferAlgorithm>*
MediaSource::GetAlgorithmRunner(int job_size) {
if (!asynchronous_reduction_enabled_ &&
job_size <= max_size_for_immediate_job_) {
// `default_algorithm_runner_` won't run jobs immediately when
// `asynchronous_reduction_enabled_` is false, so we use
// `immediate_job_algorithm_runner_` instead, which always has asynchronous
// reduction enabled.
return &immediate_job_algorithm_runner_;
}
if (!offload_algorithm_runner_) {
return &default_algorithm_runner_;
}
// The logic below is redundant as the code for immediate job can be
// consolidated with value of `asynchronous_reduction_enabled_` ignored. It's
// kept as is to leave existing behavior unchanged.
if (asynchronous_reduction_enabled_ &&
job_size <= max_size_for_immediate_job_) {
// Append without posting new tasks is only supported on the default runner.
return &default_algorithm_runner_;
}
return offload_algorithm_runner_.get();
}
void MediaSource::TraceMembers(script::Tracer* tracer) {
web::EventTarget::TraceMembers(tracer);
tracer->Trace(event_queue_);
tracer->Trace(attached_element_);
tracer->Trace(source_buffers_);
tracer->Trace(active_source_buffers_);
tracer->Trace(live_seekable_range_);
}
void MediaSource::SetReadyState(MediaSourceReadyState ready_state) {
if (!offload_algorithm_runner_) {
// Setting `chunk_demuxer_` to NULL when there is an active algorithm
// running may cause crash. So `chunk_demuxer_` is reset later in the
// function.
// When `offload_algorithm_runner_` is null, the logic is kept as is to
// ensure that the behavior stays the same when offload is not enabled.
if (ready_state == kMediaSourceReadyStateClosed) {
chunk_demuxer_ = NULL;
}
}
if (ready_state_ == ready_state) {
return;
}
MediaSourceReadyState old_state = ready_state_;
ready_state_ = ready_state;
if (IsOpen()) {
ScheduleEvent(base::Tokens::sourceopen());
return;
}
if (old_state == kMediaSourceReadyStateOpen &&
ready_state_ == kMediaSourceReadyStateEnded) {
ScheduleEvent(base::Tokens::sourceended());
return;
}
DCHECK(IsClosed());
active_source_buffers_->Clear();
// Clear SourceBuffer references to this object.
for (uint32 i = 0; i < source_buffers_->length(); ++i) {
source_buffers_->Item(i)->OnRemovedFromMediaSource();
}
source_buffers_->Clear();
attached_element_.reset();
ScheduleEvent(base::Tokens::sourceclose());
if (algorithm_process_thread_) {
algorithm_process_thread_->Stop();
algorithm_process_thread_.reset();
}
offload_algorithm_runner_.reset();
chunk_demuxer_ = NULL;
}
bool MediaSource::IsUpdating() const {
// Return true if any member of |source_buffers_| is updating.
for (uint32 i = 0; i < source_buffers_->length(); ++i) {
if (source_buffers_->Item(i)->updating()) {
return true;
}
}
return false;
}
void MediaSource::ScheduleEvent(base::Token event_name) {
scoped_refptr<web::Event> event = new web::Event(event_name);
event->set_target(this);
event_queue_.Enqueue(event);
}
} // namespace dom
} // namespace cobalt