123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557 |
- // Copyright 2020 The Chromium Authors. All rights reserved.
- // Use of this source code is governed by a BSD-style license that can be
- // found in the LICENSE file.
- #include "ash/services/recording/recording_service.h"
- #include <cmath>
- #include <cstdint>
- #include <cstdlib>
- #include "ash/constants/ash_features.h"
- #include "ash/services/recording/recording_encoder_muxer.h"
- #include "ash/services/recording/recording_service_constants.h"
- #include "ash/services/recording/video_capture_params.h"
- #include "base/bind.h"
- #include "base/callback_helpers.h"
- #include "base/check.h"
- #include "base/location.h"
- #include "base/task/task_traits.h"
- #include "base/task/thread_pool.h"
- #include "base/time/time.h"
- #include "media/audio/audio_device_description.h"
- #include "media/base/audio_codecs.h"
- #include "media/base/status.h"
- #include "media/base/video_frame.h"
- #include "media/base/video_util.h"
- #include "media/capture/mojom/video_capture_buffer.mojom.h"
- #include "media/capture/mojom/video_capture_types.mojom.h"
- #include "media/renderers/paint_canvas_video_renderer.h"
- #include "services/audio/public/cpp/device_factory.h"
- #include "third_party/abseil-cpp/absl/types/optional.h"
- #include "ui/gfx/image/image_skia_operations.h"
- namespace recording {
- namespace {
- // For a capture size of 320 by 240, we use a bitrate of 256 kbit/s. This value
- // is used as the minimum bitrate that we don't go below regardless of the video
- // size.
- constexpr uint32_t kMinBitrateInBitsPerSecond = 256 * 1000;
- // The size (in DIPs) within which we will try to fit a thumbnail image
- // extracted from the first valid video frame. The value was chosen to be
- // suitable with the image container in the notification UI.
- constexpr gfx::Size kThumbnailSize{328, 184};
- // Calculates the bitrate (in bits/seconds) used to initialize the video encoder
- // based on the given |capture_size|.
- uint32_t CalculateVpxEncoderBitrate(const gfx::Size& capture_size) {
- // We use the Kush Gauge formula which goes like this:
- // bitrate (bits/s) = width * height * frame rate * motion factor * 0.07.
- // Here we use a motion factor = 1, which works well for our use cases.
- // This formula gives a balance between the video quality and the file size so
- // it doesn't become too large.
- const uint32_t bitrate =
- std::ceil(capture_size.GetArea() * kMaxFrameRate * 0.07f);
- // Make sure to return a value that is divisible by 8 so that we work with
- // whole bytes.
- return std::max(kMinBitrateInBitsPerSecond, (bitrate & ~7));
- }
- // Given the desired |capture_size|, it creates and returns the options needed
- // to configure the video encoder.
- media::VideoEncoder::Options CreateVideoEncoderOptions(
- const gfx::Size& capture_size) {
- media::VideoEncoder::Options video_encoder_options;
- video_encoder_options.bitrate =
- media::Bitrate::ConstantBitrate(CalculateVpxEncoderBitrate(capture_size));
- video_encoder_options.framerate = kMaxFrameRate;
- video_encoder_options.frame_size = capture_size;
- // This value, expressed as a number of frames, forces the encoder to code
- // a keyframe if one has not been coded in the last keyframe_interval frames.
- video_encoder_options.keyframe_interval = 100;
- return video_encoder_options;
- }
- media::AudioParameters GetAudioParameters() {
- static_assert(kAudioSampleRate % 100 == 0,
- "Audio sample rate is not divisible by 100");
- return media::AudioParameters(media::AudioParameters::AUDIO_PCM_LOW_LATENCY,
- media::CHANNEL_LAYOUT_STEREO, kAudioSampleRate,
- kAudioSampleRate / 100);
- }
- // Extracts a potentially scaled-down RGB image from the given video |frame|,
- // which is suitable to use as a thumbnail for the video.
- gfx::ImageSkia ExtractImageFromVideoFrame(const media::VideoFrame& frame) {
- const gfx::Size visible_size = frame.visible_rect().size();
- media::PaintCanvasVideoRenderer renderer;
- SkBitmap bitmap;
- bitmap.allocN32Pixels(visible_size.width(), visible_size.height());
- renderer.ConvertVideoFrameToRGBPixels(&frame, bitmap.getPixels(),
- bitmap.rowBytes());
- // Since this image will be used as a thumbnail, we can scale it down to save
- // on memory if needed. For example, if recording a FHD display, that will be
- // (for 12 bits/pixel):
- // 1920 * 1080 * 12 / 8, which is approx. = 3 MB, which is a lot to keep
- // around for a thumbnail.
- const gfx::ImageSkia thumbnail = gfx::ImageSkia::CreateFrom1xBitmap(bitmap);
- if (visible_size.width() <= kThumbnailSize.width() &&
- visible_size.height() <= kThumbnailSize.height()) {
- return thumbnail;
- }
- const gfx::Size scaled_size =
- media::ScaleSizeToFitWithinTarget(visible_size, kThumbnailSize);
- return gfx::ImageSkiaOperations::CreateResizedImage(
- thumbnail, skia::ImageOperations::ResizeMethod::RESIZE_BETTER,
- scaled_size);
- }
- // Called when the channel to the client of the recording service gets
- // disconnected. At that point, there's nothing useful to do here, and instead
- // of wasting resources encoding/muxing remaining frames, and flushing the
- // buffers, we terminate the recording service process immediately.
- void TerminateServiceImmediately() {
- LOG(ERROR)
- << "The recording service client was disconnected. Exiting immediately.";
- std::exit(EXIT_FAILURE);
- }
- } // namespace
- RecordingService::RecordingService(
- mojo::PendingReceiver<mojom::RecordingService> receiver)
- : audio_parameters_(GetAudioParameters()),
- receiver_(this, std::move(receiver)),
- consumer_receiver_(this),
- main_task_runner_(base::ThreadTaskRunnerHandle::Get()),
- encoding_task_runner_(base::ThreadPool::CreateSequencedTaskRunner(
- // We use |USER_VISIBLE| here as opposed to |BEST_EFFORT| since the
- // latter is extremely low priority and may stall encoding for random
- // reasons.
- {base::MayBlock(), base::TaskPriority::USER_VISIBLE,
- base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN})) {
- }
- RecordingService::~RecordingService() {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- if (!current_video_capture_params_)
- return;
- // If the service gets destructed while recording in progress, the client must
- // be still connected (since otherwise the service process would have been
- // immediately terminated). We attempt to flush whatever we have right now
- // before exiting.
- DCHECK(client_remote_.is_bound());
- DCHECK(client_remote_.is_connected());
- StopRecording();
- video_capturer_remote_.reset();
- consumer_receiver_.reset();
- // Note that we can call FlushAndFinalize() on the |encoder_muxer_| even
- // though it will be done asynchronously on the |encoding_task_runner_| and by
- // then this |RecordingService| instance will have already been gone. This is
- // because the muxer writes directly to the file and does not rely on this
- // instance.
- encoder_muxer_.AsyncCall(&RecordingEncoderMuxer::FlushAndFinalize)
- .WithArgs(base::DoNothing());
- SignalRecordingEndedToClient(mojom::RecordingStatus::kServiceClosing);
- }
- void RecordingService::RecordFullscreen(
- mojo::PendingRemote<mojom::RecordingServiceClient> client,
- mojo::PendingRemote<viz::mojom::FrameSinkVideoCapturer> video_capturer,
- mojo::PendingRemote<media::mojom::AudioStreamFactory> audio_stream_factory,
- mojo::PendingRemote<mojom::DriveFsQuotaDelegate> drive_fs_quota_delegate,
- const base::FilePath& webm_file_path,
- const viz::FrameSinkId& frame_sink_id,
- const gfx::Size& frame_sink_size_dip,
- float device_scale_factor) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- StartNewRecording(
- std::move(client), std::move(video_capturer),
- std::move(audio_stream_factory), std::move(drive_fs_quota_delegate),
- webm_file_path,
- VideoCaptureParams::CreateForFullscreenCapture(
- frame_sink_id, frame_sink_size_dip, device_scale_factor));
- }
- void RecordingService::RecordWindow(
- mojo::PendingRemote<mojom::RecordingServiceClient> client,
- mojo::PendingRemote<viz::mojom::FrameSinkVideoCapturer> video_capturer,
- mojo::PendingRemote<media::mojom::AudioStreamFactory> audio_stream_factory,
- mojo::PendingRemote<mojom::DriveFsQuotaDelegate> drive_fs_quota_delegate,
- const base::FilePath& webm_file_path,
- const viz::FrameSinkId& frame_sink_id,
- const gfx::Size& frame_sink_size_dip,
- float device_scale_factor,
- const viz::SubtreeCaptureId& subtree_capture_id,
- const gfx::Size& window_size_dip) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- StartNewRecording(std::move(client), std::move(video_capturer),
- std::move(audio_stream_factory),
- std::move(drive_fs_quota_delegate), webm_file_path,
- VideoCaptureParams::CreateForWindowCapture(
- frame_sink_id, subtree_capture_id, frame_sink_size_dip,
- device_scale_factor, window_size_dip));
- }
- void RecordingService::RecordRegion(
- mojo::PendingRemote<mojom::RecordingServiceClient> client,
- mojo::PendingRemote<viz::mojom::FrameSinkVideoCapturer> video_capturer,
- mojo::PendingRemote<media::mojom::AudioStreamFactory> audio_stream_factory,
- mojo::PendingRemote<mojom::DriveFsQuotaDelegate> drive_fs_quota_delegate,
- const base::FilePath& webm_file_path,
- const viz::FrameSinkId& frame_sink_id,
- const gfx::Size& frame_sink_size_dip,
- float device_scale_factor,
- const gfx::Rect& crop_region_dip) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- StartNewRecording(std::move(client), std::move(video_capturer),
- std::move(audio_stream_factory),
- std::move(drive_fs_quota_delegate), webm_file_path,
- VideoCaptureParams::CreateForRegionCapture(
- frame_sink_id, frame_sink_size_dip, device_scale_factor,
- crop_region_dip));
- }
- void RecordingService::StopRecording() {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- video_capturer_remote_->Stop();
- if (audio_capturer_)
- audio_capturer_->Stop();
- audio_capturer_.reset();
- }
- void RecordingService::OnRecordedWindowChangingRoot(
- const viz::FrameSinkId& new_frame_sink_id,
- const gfx::Size& new_frame_sink_size_dip,
- float new_device_scale_factor) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- if (!current_video_capture_params_) {
- // A recording might terminate before we signal the client with an
- // |OnRecordingEnded()| call.
- return;
- }
- // If there's a change in the pixel size of the recorded window as a result of
- // it moving to a different display, we must reconfigure the video encoder so
- // that output video has the correct dimensions.
- if (current_video_capture_params_->OnRecordedWindowChangingRoot(
- video_capturer_remote_, new_frame_sink_id, new_frame_sink_size_dip,
- new_device_scale_factor)) {
- ReconfigureVideoEncoder();
- }
- }
- void RecordingService::OnRecordedWindowSizeChanged(
- const gfx::Size& new_window_size_dip) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- if (!current_video_capture_params_) {
- // A recording might terminate before we signal the client with an
- // |OnRecordingEnded()| call.
- return;
- }
- if (current_video_capture_params_->OnRecordedWindowSizeChanged(
- video_capturer_remote_, new_window_size_dip)) {
- ReconfigureVideoEncoder();
- }
- }
- void RecordingService::OnFrameSinkSizeChanged(
- const gfx::Size& new_frame_sink_size_dip,
- float new_device_scale_factor) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- if (!current_video_capture_params_) {
- // A recording might terminate before we signal the client with an
- // |OnRecordingEnded()| call.
- return;
- }
- // A change in the pixel size of the frame sink may result in changing the
- // pixel size of the captured target (e.g. window or region). we must
- // reconfigure the video encoder so that output video has the correct
- // dimensions.
- if (current_video_capture_params_->OnFrameSinkSizeChanged(
- video_capturer_remote_, new_frame_sink_size_dip,
- new_device_scale_factor)) {
- ReconfigureVideoEncoder();
- }
- }
- void RecordingService::OnFrameCaptured(
- media::mojom::VideoBufferHandlePtr data,
- media::mojom::VideoFrameInfoPtr info,
- const gfx::Rect& content_rect,
- mojo::PendingRemote<viz::mojom::FrameSinkVideoConsumerFrameCallbacks>
- callbacks) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- DCHECK(encoder_muxer_);
- CHECK(data->is_read_only_shmem_region());
- base::ReadOnlySharedMemoryRegion& shmem_region =
- data->get_read_only_shmem_region();
- // The |data| parameter is not nullable and mojo type mapping for
- // `base::ReadOnlySharedMemoryRegion` defines that nullable version of it is
- // the same type, with null check being equivalent to IsValid() check. Given
- // the above, we should never be able to receive a read only shmem region that
- // is not valid - mojo will enforce it for us.
- DCHECK(shmem_region.IsValid());
- // We ignore any subsequent frames after a failure.
- if (did_failure_occur_)
- return;
- base::ReadOnlySharedMemoryMapping mapping = shmem_region.Map();
- if (!mapping.IsValid()) {
- DLOG(ERROR) << "Mapping of video frame shared memory failed.";
- return;
- }
- if (mapping.size() <
- media::VideoFrame::AllocationSize(info->pixel_format, info->coded_size)) {
- DLOG(ERROR) << "Shared memory size was less than expected.";
- return;
- }
- if (!info->color_space) {
- DLOG(ERROR) << "Missing mandatory color space info.";
- return;
- }
- DCHECK(current_video_capture_params_);
- const gfx::Rect& visible_rect =
- current_video_capture_params_->GetVideoFrameVisibleRect(
- info->visible_rect);
- scoped_refptr<media::VideoFrame> frame = media::VideoFrame::WrapExternalData(
- info->pixel_format, info->coded_size, visible_rect, visible_rect.size(),
- reinterpret_cast<uint8_t*>(const_cast<void*>(mapping.memory())),
- mapping.size(), info->timestamp);
- if (!frame) {
- DLOG(ERROR) << "Failed to create a VideoFrame.";
- return;
- }
- // Takes ownership of |mapping| and |callbacks| to keep them alive until
- // |frame| is released.
- frame->AddDestructionObserver(base::BindOnce(
- [](base::ReadOnlySharedMemoryMapping mapping,
- mojo::PendingRemote<viz::mojom::FrameSinkVideoConsumerFrameCallbacks>
- callbacks) {},
- std::move(mapping), std::move(callbacks)));
- frame->set_metadata(info->metadata);
- frame->set_color_space(info->color_space.value());
- if (video_thumbnail_.isNull())
- video_thumbnail_ = ExtractImageFromVideoFrame(*frame);
- if (on_video_frame_delivered_callback_for_testing_) {
- std::move(on_video_frame_delivered_callback_for_testing_)
- .Run(*frame, content_rect);
- }
- encoder_muxer_.AsyncCall(&RecordingEncoderMuxer::EncodeVideo).WithArgs(frame);
- }
- void RecordingService::OnNewCropVersion(uint32_t crop_version) {}
- void RecordingService::OnFrameWithEmptyRegionCapture() {}
- void RecordingService::OnStopped() {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- // If a failure occurred, we don't wait till the capturer sends us this
- // signal. The recording had already been terminated by now.
- if (!did_failure_occur_)
- TerminateRecording(mojom::RecordingStatus::kSuccess);
- }
- void RecordingService::OnLog(const std::string& message) {
- DLOG(WARNING) << message;
- }
- void RecordingService::OnCaptureStarted() {}
- void RecordingService::Capture(const media::AudioBus* audio_source,
- base::TimeTicks audio_capture_time,
- double volume,
- bool key_pressed) {
- // This is called on a worker thread created by the |audio_capturer_| (See
- // |media::AudioDeviceThread|. The given |audio_source| wraps audio data in a
- // shared memory with the audio service. Calling |audio_capturer_->Stop()|
- // will destroy that thread and the shared memory mapping before we get a
- // chance to encode and flush the remaining frames (See
- // media::AudioInputDevice::Stop(), and
- // media::AudioInputDevice::AudioThreadCallback::Process() for details). It is
- // safer that we own our AudioBuses that are kept alive until encoded and
- // flushed.
- auto audio_data =
- media::AudioBus::Create(audio_source->channels(), audio_source->frames());
- audio_source->CopyTo(audio_data.get());
- main_task_runner_->PostTask(
- FROM_HERE, base::BindOnce(&RecordingService::OnAudioCaptured,
- weak_ptr_factory_.GetWeakPtr(),
- std::move(audio_data), audio_capture_time));
- }
- void RecordingService::OnCaptureError(
- media::AudioCapturerSource::ErrorCode code,
- const std::string& message) {
- LOG(ERROR) << "AudioCaptureError: code=" << static_cast<uint32_t>(code)
- << ", " << message;
- }
- void RecordingService::OnCaptureMuted(bool is_muted) {}
- void RecordingService::StartNewRecording(
- mojo::PendingRemote<mojom::RecordingServiceClient> client,
- mojo::PendingRemote<viz::mojom::FrameSinkVideoCapturer> video_capturer,
- mojo::PendingRemote<media::mojom::AudioStreamFactory> audio_stream_factory,
- mojo::PendingRemote<mojom::DriveFsQuotaDelegate> drive_fs_quota_delegate,
- const base::FilePath& webm_file_path,
- std::unique_ptr<VideoCaptureParams> capture_params) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- if (current_video_capture_params_) {
- LOG(ERROR) << "Cannot start a new recording while another is in progress.";
- return;
- }
- client_remote_.reset();
- client_remote_.Bind(std::move(client));
- client_remote_.set_disconnect_handler(
- base::BindOnce(&TerminateServiceImmediately));
- current_video_capture_params_ = std::move(capture_params);
- const bool should_record_audio = audio_stream_factory.is_valid();
- encoder_muxer_ = RecordingEncoderMuxer::Create(
- encoding_task_runner_,
- CreateVideoEncoderOptions(current_video_capture_params_->GetVideoSize()),
- should_record_audio ? &audio_parameters_ : nullptr,
- std::move(drive_fs_quota_delegate), webm_file_path,
- BindOnceToMainThread(&RecordingService::OnEncodingFailure));
- ConnectAndStartVideoCapturer(std::move(video_capturer));
- if (!should_record_audio)
- return;
- audio_capturer_ = audio::CreateInputDevice(
- std::move(audio_stream_factory),
- std::string(media::AudioDeviceDescription::kDefaultDeviceId),
- audio::DeadStreamDetection::kEnabled);
- DCHECK(audio_capturer_);
- audio_capturer_->Initialize(audio_parameters_, this);
- audio_capturer_->Start();
- }
- void RecordingService::ReconfigureVideoEncoder() {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- DCHECK(current_video_capture_params_);
- ++number_of_video_encoder_reconfigures_;
- encoder_muxer_.AsyncCall(&RecordingEncoderMuxer::InitializeVideoEncoder)
- .WithArgs(CreateVideoEncoderOptions(
- current_video_capture_params_->GetVideoSize()));
- }
- void RecordingService::TerminateRecording(mojom::RecordingStatus status) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- DCHECK(encoder_muxer_);
- current_video_capture_params_.reset();
- video_capturer_remote_.reset();
- consumer_receiver_.reset();
- encoder_muxer_.AsyncCall(&RecordingEncoderMuxer::FlushAndFinalize)
- .WithArgs(BindOnceToMainThread(&RecordingService::OnEncoderMuxerFlushed,
- status));
- }
- void RecordingService::ConnectAndStartVideoCapturer(
- mojo::PendingRemote<viz::mojom::FrameSinkVideoCapturer> video_capturer) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- DCHECK(current_video_capture_params_);
- video_capturer_remote_.reset();
- video_capturer_remote_.Bind(std::move(video_capturer));
- // The GPU process could crash while recording is in progress, and the video
- // capturer will be disconnected. We need to handle this event gracefully.
- video_capturer_remote_.set_disconnect_handler(base::BindOnce(
- &RecordingService::OnVideoCapturerDisconnected, base::Unretained(this)));
- current_video_capture_params_->InitializeVideoCapturer(
- video_capturer_remote_);
- video_capturer_remote_->Start(consumer_receiver_.BindNewPipeAndPassRemote(),
- viz::mojom::BufferFormatPreference::kDefault);
- }
- void RecordingService::OnVideoCapturerDisconnected() {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- // On a crash in the GPU, the video capturer gets disconnected, so we can't
- // communicate with it any longer, but we can still communicate with the audio
- // capturer. We will stop the recording and flush whatever video chunks we
- // currently have.
- did_failure_occur_ = true;
- if (audio_capturer_)
- audio_capturer_->Stop();
- audio_capturer_.reset();
- TerminateRecording(mojom::RecordingStatus::kVizVideoCapturerDisconnected);
- }
- void RecordingService::OnAudioCaptured(
- std::unique_ptr<media::AudioBus> audio_bus,
- base::TimeTicks audio_capture_time) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- DCHECK(encoder_muxer_);
- // We ignore any subsequent frames after a failure.
- if (did_failure_occur_)
- return;
- encoder_muxer_.AsyncCall(&RecordingEncoderMuxer::EncodeAudio)
- .WithArgs(std::move(audio_bus), audio_capture_time);
- }
- void RecordingService::OnEncodingFailure(mojom::RecordingStatus status) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- did_failure_occur_ = true;
- StopRecording();
- // We don't wait for the video capturer to send us the OnStopped() signal, we
- // terminate recording immediately. We still need to flush the encoders, and
- // muxer since they may contain valid frames from before the failure occurred,
- // that we can propagate to the client.
- TerminateRecording(status);
- }
- void RecordingService::OnEncoderMuxerFlushed(mojom::RecordingStatus status) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- SignalRecordingEndedToClient(status);
- }
- void RecordingService::SignalRecordingEndedToClient(
- mojom::RecordingStatus status) {
- DCHECK_CALLED_ON_VALID_THREAD(main_thread_checker_);
- DCHECK(encoder_muxer_);
- encoder_muxer_.Reset();
- client_remote_->OnRecordingEnded(status, video_thumbnail_);
- }
- } // namespace recording
|