blob: bbc4191fb7dad304866b56ff06e5fd614ceef265 [file] [log] [blame]
/*
* Copyright 2021 The Android Open Source Project
*
* 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.
*/
package androidx.camera.video;
import static androidx.camera.video.QualitySelector.FALLBACK_STRATEGY_HIGHER;
import static androidx.camera.video.QualitySelector.QUALITY_FHD;
import static androidx.camera.video.QualitySelector.QUALITY_HD;
import static androidx.camera.video.QualitySelector.QUALITY_SD;
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED;
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED;
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_INVALID_OUTPUT_OPTIONS;
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE;
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA;
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_RECORDER_ERROR;
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE;
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_UNKNOWN;
import static androidx.camera.video.VideoRecordEvent.Finalize.VideoRecordError;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.ContentValues;
import android.content.Context;
import android.media.MediaCodecInfo;
import android.media.MediaMuxer;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.provider.MediaStore;
import android.util.Size;
import android.view.Surface;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RequiresPermission;
import androidx.annotation.RestrictTo;
import androidx.camera.core.AspectRatio;
import androidx.camera.core.Logger;
import androidx.camera.core.SurfaceRequest;
import androidx.camera.core.impl.MutableStateObservable;
import androidx.camera.core.impl.Observable;
import androidx.camera.core.impl.StateObservable;
import androidx.camera.core.impl.annotation.ExecutedBy;
import androidx.camera.core.impl.utils.executor.CameraXExecutors;
import androidx.camera.core.impl.utils.futures.FutureCallback;
import androidx.camera.core.impl.utils.futures.Futures;
import androidx.camera.video.internal.AudioSource;
import androidx.camera.video.internal.AudioSourceAccessException;
import androidx.camera.video.internal.BufferProvider;
import androidx.camera.video.internal.compat.Api26Impl;
import androidx.camera.video.internal.compat.quirk.DeactivateEncoderSurfaceBeforeStopEncoderQuirk;
import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
import androidx.camera.video.internal.encoder.AudioEncoderConfig;
import androidx.camera.video.internal.encoder.EncodeException;
import androidx.camera.video.internal.encoder.EncodedData;
import androidx.camera.video.internal.encoder.Encoder;
import androidx.camera.video.internal.encoder.EncoderCallback;
import androidx.camera.video.internal.encoder.EncoderImpl;
import androidx.camera.video.internal.encoder.InputBuffer;
import androidx.camera.video.internal.encoder.InvalidConfigException;
import androidx.camera.video.internal.encoder.OutputConfig;
import androidx.camera.video.internal.encoder.VideoEncoderConfig;
import androidx.camera.video.internal.utils.OutputUtil;
import androidx.concurrent.futures.CallbackToFutureAdapter;
import androidx.core.util.Consumer;
import androidx.core.util.Preconditions;
import com.google.auto.value.AutoValue;
import com.google.common.util.concurrent.ListenableFuture;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* An implementation of {@link VideoOutput} for starting video recordings that are saved
* to a {@link File}, {@link ParcelFileDescriptor}, or {@link MediaStore}.
*
* <p>A recorder can be used to save the video frames sent from the {@link VideoCapture} use case
* in common recording formats such as MPEG4.
*
* <p>Usage example of setting up {@link VideoCapture} with a recorder as output:
* <pre>
* ProcessCameraProvider cameraProvider = ...;
* CameraSelector cameraSelector = ...;
* ...
* // Create our preview to show on screen
* Preview preview = new Preview.Builder.build();
* // Create the video capture use case with a Recorder as the output
* VideoCapture<Recorder> videoCapture = VideoCapture.withOutput(new Recorder.Builder().build());
*
* // Bind use cases to Fragment/Activity lifecycle
* cameraProvider.bindToLifecycle(this, cameraSelector, preview, videoCapture);
* </pre>
*
* <p>Once the recorder is attached to a video source as a {@link VideoOutput}, e.g. using it to
* create a {@link VideoCapture} by calling {@link VideoCapture#withOutput(VideoOutput)}, a new
* recording can be generated with one of the prepareRecording methods, such as
* {@link #prepareRecording(Context, MediaStoreOutputOptions)}. The {@link PendingRecording} class
* then can be used to adjust per-recording settings and to start the recording. It also allows
* setting a listener with {@link PendingRecording#withEventListener(Executor, Consumer)} to
* listen for {@link VideoRecordEvent}s such as {@link VideoRecordEvent.Start},
* {@link VideoRecordEvent.Pause}, {@link VideoRecordEvent.Resume}, and
* {@link VideoRecordEvent.Finalize}. This listener will also receive regular recording status
* updates via the {@link VideoRecordEvent.Status} event.
*
* <p>A recorder can also capture and save audio alongside video. The audio must be explicitly
* enabled with {@link PendingRecording#withAudioEnabled()} before starting the recording.
*
* @see VideoCapture#withOutput(VideoOutput)
* @see PendingRecording
*/
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
public final class Recorder implements VideoOutput {
private static final String TAG = "Recorder";
enum State {
/**
* The Recorder is being initialized.
*/
INITIALIZING,
/**
* The Recorder is being initialized and a recording is waiting for being run.
*/
PENDING_RECORDING,
/**
* The Recorder is being initialized and a recording is waiting for being paused.
*/
PENDING_PAUSED,
/**
* The Recorder is idling and ready to start a new recording.
*/
IDLING,
/**
* There's a running recording and the Recorder is producing output.
*/
RECORDING,
/**
* There's a running recording and it's paused.
*/
PAUSED,
/**
* There's a recording being stopped.
*/
STOPPING,
/**
* There's a running recording and the Recorder is being reset.
*/
RESETTING,
/**
* The Recorder encountered errors and any operation will attempt will throw an
* {@link IllegalStateException}. Users can handle the error by monitoring
* {@link VideoRecordEvent}.
*/
ERROR
}
enum AudioState {
/**
* The audio is being initializing.
*/
INITIALIZING,
/**
* Audio recording is disabled for the running recording.
*/
DISABLED,
/**
* The recording is being recorded with audio.
*/
ACTIVE,
/**
* The recording is muted because the audio source is silenced.
*/
SOURCE_SILENCED,
/**
* The recording is muted because the audio encoder encountered errors.
*/
ENCODER_ERROR
}
/**
* The subset of states considered pending states.
*/
private static final Set<State> PENDING_STATES =
Collections.unmodifiableSet(EnumSet.of(State.PENDING_RECORDING, State.PENDING_PAUSED));
/**
* The subset of states which are valid non-pending states while in a pending state.
*
* <p>All other states should not be possible if in a PENDING_* state. Pending states are
* meant to be transient states that occur while waiting for another operation to finish.
*/
private static final Set<State> VALID_NON_PENDING_STATES_WHILE_PENDING =
Collections.unmodifiableSet(EnumSet.of(
State.INITIALIZING, // Waiting for camera before starting recording.
State.IDLING, // Waiting for sequential executor to start pending recording.
State.RESETTING, // Waiting for camera/encoders to reset before starting.
State.STOPPING // Waiting for previous recording to finalize before starting.
));
/**
* Default quality selector for recordings.
*
* <p>The default quality selector chooses a video quality suitable for recordings based on
* device and compatibility constraints. It is equivalent to:
* <pre>{@code
* QualitySelector.firstTry(QUALITY_FHD)
* .thenTry(QUALITY_HD)
* .thenTry(QUALITY_SD)
* .finallyTry(QUALITY_FHD, FALLBACK_STRATEGY_HIGHER);
* }</pre>
*
* @see QualitySelector
*/
public static final QualitySelector DEFAULT_QUALITY_SELECTOR =
QualitySelector.firstTry(QUALITY_FHD)
.thenTry(QUALITY_HD)
.thenTry(QUALITY_SD)
.finallyTry(QUALITY_FHD, FALLBACK_STRATEGY_HIGHER);
private static final AudioSpec AUDIO_SPEC_DEFAULT =
AudioSpec.builder()
.setSourceFormat(
AudioSpec.SOURCE_FORMAT_PCM_16BIT) /* Defaults to PCM_16BIT as it's
guaranteed supported on devices. May consider allowing users to set
format through AudioSpec later. */
.setSource(AudioSpec.SOURCE_CAMCORDER)
.setChannelCount(AudioSpec.CHANNEL_COUNT_MONO)
.build();
private static final VideoSpec VIDEO_SPEC_DEFAULT =
VideoSpec.builder()
.setQualitySelector(DEFAULT_QUALITY_SELECTOR)
.setAspectRatio(VideoSpec.ASPECT_RATIO_16_9)
.build();
private static final MediaSpec MEDIA_SPEC_DEFAULT =
MediaSpec.builder()
.setOutputFormat(MediaSpec.OUTPUT_FORMAT_MPEG_4)
.setAudioSpec(AUDIO_SPEC_DEFAULT)
.setVideoSpec(VIDEO_SPEC_DEFAULT)
.build();
private static final int AUDIO_BITRATE_DEFAULT = 88200;
// Default to 44100 for now as it's guaranteed supported on devices.
private static final int AUDIO_SAMPLE_RATE_DEFAULT = 44100;
private static final int VIDEO_FRAME_RATE_DEFAULT = 30;
private static final int VIDEO_BITRATE_DEFAULT = 10 * 1024 * 1024; // 10M
private static final int VIDEO_INTRA_FRAME_INTERVAL_DEFAULT = 1;
@SuppressWarnings("deprecation")
private static final String MEDIA_COLUMN = MediaStore.Video.Media.DATA;
private static final Exception PENDING_RECORDING_ERROR_CAUSE_SOURCE_INACTIVE =
new RuntimeException("The video frame producer became inactive before any "
+ "data was received.");
private static final int PENDING = 1;
private static final int NOT_PENDING = 0;
private final MutableStateObservable<StreamState> mStreamState =
MutableStateObservable.withInitialState(StreamState.INACTIVE);
// Used only by getExecutor()
private final Executor mUserProvidedExecutor;
// May be equivalent to mUserProvidedExecutor or an internal executor if the user did not
// provide an executor.
private final Executor mExecutor;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
final Executor mSequentialExecutor;
private final Object mLock = new Object();
////////////////////////////////////////////////////////////////////////////////////////////////
// Members only accessed when holding mLock //
////////////////////////////////////////////////////////////////////////////////////////////////
@GuardedBy("mLock")
private State mState = State.INITIALIZING;
// Tracks the underlying state when in a PENDING_* state. When not in a PENDING_* state, this
// should be null.
@GuardedBy("mLock")
private State mNonPendingState = null;
@GuardedBy("mLock")
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
RecordingRecord mActiveRecordingRecord = null;
// A recording that will be started once the previous recording has finalized or the
// recorder has finished initializing.
@GuardedBy("mLock")
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
RecordingRecord mPendingRecordingRecord = null;
@GuardedBy("mLock")
private SourceState mSourceState = SourceState.ACTIVE;
@GuardedBy("mLock")
private Throwable mErrorCause;
@GuardedBy("mLock")
private boolean mSurfaceRequested = false;
@GuardedBy("mLock")
private long mLastGeneratedRecordingId = 0L;
//--------------------------------------------------------------------------------------------//
////////////////////////////////////////////////////////////////////////////////////////////////
// Members only accessed on mSequentialExecutor //
////////////////////////////////////////////////////////////////////////////////////////////////
private RecordingRecord mInProgressRecording = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
boolean mInProgressRecordingStopping = false;
private boolean mAudioInitialized = false;
private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
final List<ListenableFuture<Void>> mEncodingFutures = new ArrayList<>();
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
Integer mAudioTrackIndex = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
Integer mVideoTrackIndex = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
Surface mSurface = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
MediaMuxer mMediaMuxer = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
final MutableStateObservable<MediaSpec> mMediaSpec;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
AudioSource mAudioSource = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
EncoderImpl mVideoEncoder = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
OutputConfig mVideoOutputConfig = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
EncoderImpl mAudioEncoder = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
OutputConfig mAudioOutputConfig = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
AudioState mAudioState = AudioState.INITIALIZING;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@NonNull Uri mOutputUri = Uri.EMPTY;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
long mRecordingBytes = 0L;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
long mRecordingDurationNs = 0L;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
long mFirstRecordingVideoDataTimeUs = 0L;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
long mFileSizeLimitInBytes = OutputOptions.FILE_SIZE_UNLIMITED;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@VideoRecordError
int mRecordingStopError = ERROR_UNKNOWN;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
Throwable mRecordingStopErrorCause = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
AudioState mCachedAudioState;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
EncodedData mPendingFirstVideoData = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
EncodedData mPendingFirstAudioData = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
Throwable mAudioErrorCause = null;
//--------------------------------------------------------------------------------------------//
Recorder(@Nullable Executor executor, @NonNull MediaSpec mediaSpec) {
mUserProvidedExecutor = executor;
mExecutor = executor != null ? executor : CameraXExecutors.ioExecutor();
mSequentialExecutor = CameraXExecutors.newSequentialExecutor(mExecutor);
mMediaSpec = MutableStateObservable.withInitialState(composeRecorderMediaSpec(mediaSpec));
}
@Override
public void onSurfaceRequested(@NonNull SurfaceRequest request) {
synchronized (mLock) {
switch (mState) {
case RESETTING:
// Fall-through
case PENDING_RECORDING:
// Fall-through
case PENDING_PAUSED:
// Fall-through
case INITIALIZING:
// The recorder should be initialized only once until it is released.
// TODO (b/198551531): Make this code more robust to multiple SurfaceRequests.
if (!mSurfaceRequested) {
mSurfaceRequested = true;
mSequentialExecutor.execute(() -> initializeInternal(request));
}
break;
case IDLING:
// Fall-through
case RECORDING:
// Fall-through
case STOPPING:
// Fall-through
case PAUSED:
throw new IllegalStateException("Surface was requested when the Recorder had "
+ "been initialized with state " + mState);
case ERROR:
throw new IllegalStateException("Surface was requested when the Recorder had "
+ "encountered error " + mErrorCause);
}
}
}
/** @hide */
@RestrictTo(RestrictTo.Scope.LIBRARY)
@Override
@NonNull
public Observable<MediaSpec> getMediaSpec() {
return mMediaSpec;
}
/** @hide */
@RestrictTo(RestrictTo.Scope.LIBRARY)
@Override
@NonNull
public Observable<StreamState> getStreamState() {
return mStreamState;
}
/** @hide */
@RestrictTo(RestrictTo.Scope.LIBRARY)
@Override
public void onSourceStateChanged(@NonNull SourceState newState) {
RecordingRecord pendingRecordingToFinalize = null;
synchronized (mLock) {
SourceState oldState = mSourceState;
mSourceState = newState;
if (oldState == SourceState.ACTIVE && newState == SourceState.INACTIVE) {
Logger.d(TAG, "Video source has transitioned to an INACTIVE state.");
switch (mState) {
case PENDING_RECORDING:
// Fall-through
case PENDING_PAUSED:
// Immediately finalize pending recording since it never started.
pendingRecordingToFinalize = mPendingRecordingRecord;
mPendingRecordingRecord = null;
restoreNonPendingState(); // Equivalent to setState(mNonPendingState)
break;
case PAUSED:
// Fall-through
case RECORDING:
setState(State.STOPPING);
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
ERROR_SOURCE_INACTIVE, null));
break;
case STOPPING:
// Fall-through
case RESETTING:
// We are already stopping or resetting, nothing needs to be done.
break;
case INITIALIZING:
// Fall-through
case IDLING:
break;
case ERROR:
// In an error state, the recording will already be finalized. Nothing
// needs to be done.
break;
}
} else if (oldState == SourceState.INACTIVE && newState == SourceState.ACTIVE) {
Logger.d(TAG, "Video source has transitioned to an ACTIVE state.");
}
}
if (pendingRecordingToFinalize != null) {
finalizePendingRecording(pendingRecordingToFinalize, ERROR_SOURCE_INACTIVE,
PENDING_RECORDING_ERROR_CAUSE_SOURCE_INACTIVE);
}
}
/**
* Prepares a recording that will be saved to a {@link File}.
*
* <p>The provided {@link FileOutputOptions} specifies the file to use.
*
* <p>Calling this method multiple times will generate multiple {@link PendingRecording}s,
* each of the recordings can be used to adjust per-recording settings individually. The
* recording will not begin until {@link PendingRecording#start()} is called. Only a single
* pending recording can be started per {@link Recorder} instance.
*
* @param context the context used to enforce runtime permissions, interface with the media
* scanner service, and attribute access to permission protected data, such as
* audio. If using this context to <a href="{@docRoot}guide
* /topics/data/audit-access#audit-by-attribution-tagaudit">audit audio
* access</a> on API level 31+, a context created with
* {@link Context#createAttributionContext(String)} should be used.
* @param fileOutputOptions the options that configures how the output will be handled.
* @return a {@link PendingRecording} that is associated with this Recorder.
* @see FileOutputOptions
*/
@NonNull
public PendingRecording prepareRecording(@NonNull Context context,
@NonNull FileOutputOptions fileOutputOptions) {
return prepareRecordingInternal(context, fileOutputOptions);
}
/**
* Prepares a recording that will be saved to a {@link ParcelFileDescriptor}.
*
* <p>The provided {@link FileDescriptorOutputOptions} specifies the
* {@link ParcelFileDescriptor} to use.
*
* <p>Currently, file descriptors as output destinations are not supported on pre-Android O
* (API 26) devices.
*
* <p>Calling this method multiple times will generate multiple {@link PendingRecording}s,
* each of the recordings can be used to adjust per-recording settings individually. The
* recording will not begin until {@link PendingRecording#start()} is called. Only a single
* pending recording can be started per {@link Recorder} instance.
*
* @param context the context used to enforce runtime permissions, interface with the media
* scanner service, and attribute access to permission protected data, such as
* audio. If using this context to <a href="{@docRoot}guide
* /topics/data/audit-access#audit-by-attribution-tagaudit">audit audio
* access</a> on API level 31+, a context created with
* {@link Context#createAttributionContext(String)} should be used.
* @param fileDescriptorOutputOptions the options that configures how the output will be
* handled.
* @return a {@link PendingRecording} that is associated with this Recorder.
* @throws UnsupportedOperationException if this method is called on per-Android O (API 26)
* devices.
* @see FileDescriptorOutputOptions
*/
@RequiresApi(26)
@NonNull
public PendingRecording prepareRecording(@NonNull Context context,
@NonNull FileDescriptorOutputOptions fileDescriptorOutputOptions) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
throw new UnsupportedOperationException(
"File descriptors as output destinations are not supported on pre-Android O "
+ "(API 26) devices.");
}
return prepareRecordingInternal(context, fileDescriptorOutputOptions);
}
/**
* Prepares a recording that will be saved to a {@link MediaStore}.
*
* <p>The provided {@link MediaStoreOutputOptions} specifies the options which will be used
* to save the recording to a {@link MediaStore}.
*
* <p>Calling this method multiple times will generate multiple {@link PendingRecording}s,
* each of the recordings can be used to adjust per-recording settings individually. The
* recording will not begin until {@link PendingRecording#start()} is called. Only a single
* pending recording can be started per {@link Recorder} instance.
*
* @param context the context used to enforce runtime permissions, interface with the media
* scanner service, and attribute access to permission protected data, such as
* audio. If using this context to <a href="{@docRoot}guide
* /topics/data/audit-access#audit-by-attribution-tagaudit">audit audio
* access</a> on API level 31+, a context created with
* {@link Context#createAttributionContext(String)} should be used.
* @param mediaStoreOutputOptions the options that configures how the output will be handled.
* @return a {@link PendingRecording} that is associated with this Recorder.
* @see MediaStoreOutputOptions
*/
@NonNull
public PendingRecording prepareRecording(@NonNull Context context,
@NonNull MediaStoreOutputOptions mediaStoreOutputOptions) {
return prepareRecordingInternal(context, mediaStoreOutputOptions);
}
@NonNull
private PendingRecording prepareRecordingInternal(@NonNull Context context,
@NonNull OutputOptions options) {
Preconditions.checkNotNull(options, "The OutputOptions cannot be null.");
return new PendingRecording(context, this, options);
}
/**
* Gets the quality selector of this Recorder.
*
* @return the {@link QualitySelector} provided to
* {@link Builder#setQualitySelector(QualitySelector)} on the builder used to create this
* recorder, or the default value of {@link Recorder#DEFAULT_QUALITY_SELECTOR} if no quality
* selector was provided.
*/
@NonNull
public QualitySelector getQualitySelector() {
return getObservableData(mMediaSpec).getVideoSpec().getQualitySelector();
}
/**
* Gets the audio source of this Recorder.
*
* @return the value provided to {@link Builder#setAudioSource(int)} on the builder used to
* create this recorder, or the default value of {@link AudioSpec#SOURCE_AUTO} if no source was
* set.
*/
@AudioSpec.Source
int getAudioSource() {
return getObservableData(mMediaSpec).getAudioSpec().getSource();
}
/**
* Returns the executor provided to the builder for this recorder.
*
* @return the {@link Executor} provided to {@link Builder#setExecutor(Executor)} on the
* builder used to create this recorder. If no executor was provided, returns {code null}.
*/
@Nullable
public Executor getExecutor() {
return mUserProvidedExecutor;
}
/**
* Gets the aspect ratio of this Recorder.
*/
@VideoSpec.AspectRatio
int getAspectRatio() {
return getObservableData(mMediaSpec).getVideoSpec().getAspectRatio();
}
/**
* Starts a pending recording and returns an active recording instance.
*
* <p>If the Recorder is already running a recording, an {@link IllegalStateException} will
* be thrown when calling this method.
*
* <p>If the video encoder hasn't been setup with {@link #onSurfaceRequested(SurfaceRequest)}
* , the {@link PendingRecording} specified will be started once the video encoder setup
* completes. The recording will be considered active, so before it's finalized, an
* {@link IllegalStateException} will be thrown if this method is called for a second time.
*
* <p>If the video producer stops sending frames to the provided surface, the recording will
* be automatically finalized with {@link VideoRecordEvent.Finalize#ERROR_SOURCE_INACTIVE}.
* This can happen, for example, when the {@link VideoCapture} this Recorder is associated
* with is detached from the camera.
*
* @throws IllegalStateException if there's an active recording, or the audio is
* {@link PendingRecording#withAudioEnabled() enabled} for the
* recording but
* {@link android.Manifest.permission#RECORD_AUDIO} is not
* granted.
*/
@NonNull
ActiveRecording start(@NonNull PendingRecording pendingRecording) {
Preconditions.checkNotNull(pendingRecording, "The given PendingRecording cannot be null.");
RecordingRecord alreadyInProgressRecording = null;
@VideoRecordError int error = ERROR_NONE;
Throwable errorCause = null;
long recordingId;
synchronized (mLock) {
recordingId = ++mLastGeneratedRecordingId;
if (mSourceState == SourceState.INACTIVE) {
error = ERROR_SOURCE_INACTIVE;
errorCause = PENDING_RECORDING_ERROR_CAUSE_SOURCE_INACTIVE;
} else {
switch (mState) {
case PAUSED:
// Fall-through
case RECORDING:
alreadyInProgressRecording = mActiveRecordingRecord;
break;
case PENDING_PAUSED:
// Fall-through
case PENDING_RECORDING:
// There is already a recording pending that hasn't been stopped.
alreadyInProgressRecording =
Preconditions.checkNotNull(mPendingRecordingRecord);
break;
case RESETTING:
// Fall-through
case STOPPING:
// Fall-through
case INITIALIZING:
mPendingRecordingRecord = RecordingRecord.from(pendingRecording,
recordingId);
// The recording will automatically start once the initialization completes.
setState(State.PENDING_RECORDING);
break;
case IDLING:
Preconditions.checkState(
mActiveRecordingRecord == null && mPendingRecordingRecord == null,
"Expected recorder to be idle but a recording is either pending or "
+ "in progress.");
mPendingRecordingRecord = RecordingRecord.from(pendingRecording,
recordingId);
setState(State.PENDING_RECORDING);
mSequentialExecutor.execute(this::tryServicePendingRecording);
break;
case ERROR:
error = ERROR_RECORDER_ERROR;
errorCause = mErrorCause;
break;
}
}
}
if (alreadyInProgressRecording != null) {
throw new IllegalStateException("A recording is already in progress. Previous "
+ "recordings must be stopped before a new recording can be started.");
} else if (error != ERROR_NONE) {
Logger.e(TAG,
"Recording was started when the Recorder had encountered error " + errorCause);
// Immediately update the listener if the Recorder encountered an error.
finalizePendingRecording(RecordingRecord.from(pendingRecording, recordingId),
error, errorCause);
return ActiveRecording.createFinalizedFrom(pendingRecording, recordingId);
}
return ActiveRecording.from(pendingRecording, recordingId);
}
void pause(@NonNull ActiveRecording activeRecording) {
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
activeRecording, mActiveRecordingRecord)) {
// If this ActiveRecording is no longer active, log and treat as a no-op.
// This is not technically an error since the recording can be finalized
// asynchronously.
Logger.d(TAG,
"pause() called on a recording that is no longer active: "
+ activeRecording.getOutputOptions());
return;
}
switch (mState) {
case PENDING_RECORDING:
// The recording will automatically pause once the initialization completes.
setState(State.PENDING_PAUSED);
break;
case INITIALIZING:
// Fall-through
case IDLING:
throw new IllegalStateException("Called pause() from invalid state: " + mState);
case RECORDING:
setState(State.PAUSED);
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
mSequentialExecutor.execute(() -> pauseInternal(finalActiveRecordingRecord));
break;
case PENDING_PAUSED:
// Fall-through
case PAUSED:
// No-op when the recording is already paused.
break;
case RESETTING:
// Fall-through
case STOPPING:
// If recorder is resetting or stopping, then pause is a no-op.
break;
case ERROR:
// In an error state, the recording will already be finalized. Treat as a
// no-op in pause()
break;
}
}
}
void resume(@NonNull ActiveRecording activeRecording) {
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
activeRecording, mActiveRecordingRecord)) {
// If this ActiveRecording is no longer active, log and treat as a no-op.
// This is not technically an error since the recording can be finalized
// asynchronously.
Logger.d(TAG,
"resume() called on a recording that is no longer active: "
+ activeRecording.getOutputOptions());
return;
}
switch (mState) {
case PENDING_PAUSED:
// The recording will automatically start once the initialization completes.
setState(State.PENDING_RECORDING);
break;
case INITIALIZING:
// Should not be able to resume when initializing. Should be in a PENDING state.
// Fall-through
case IDLING:
throw new IllegalStateException("Called resume() from invalid state: "
+ mState);
case RESETTING:
// Fall-through
case STOPPING:
// If recorder is stopping or resetting, then resume is a no-op.
// Fall-through
case PENDING_RECORDING:
// Fall-through
case RECORDING:
// No-op when the recording is running.
break;
case PAUSED:
setState(State.RECORDING);
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
mSequentialExecutor.execute(() -> resumeInternal(finalActiveRecordingRecord));
break;
case ERROR:
// In an error state, the recording will already be finalized. Treat as a
// no-op in resume()
break;
}
}
}
void stop(@NonNull ActiveRecording activeRecording) {
RecordingRecord pendingRecordingToFinalize = null;
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
activeRecording, mActiveRecordingRecord)) {
// If this ActiveRecording is no longer active, log and treat as a no-op.
// This is not technically an error since the recording can be finalized
// asynchronously.
Logger.d(TAG,
"stop() called on a recording that is no longer active: "
+ activeRecording.getOutputOptions());
return;
}
switch (mState) {
case PENDING_RECORDING:
// Fall-through
case PENDING_PAUSED:
// Immediately finalize pending recording since it never started.
Preconditions.checkState(isSameRecording(activeRecording,
mPendingRecordingRecord));
pendingRecordingToFinalize = mPendingRecordingRecord;
mPendingRecordingRecord = null;
restoreNonPendingState(); // Equivalent to setState(mNonPendingState)
break;
case STOPPING:
// Fall-through
case RESETTING:
// We are already resetting, likely due to an error that stopped the recording.
// Ensure this is the current active recording and treat as a no-op. The
// active recording will be cleared once stop/reset is complete.
Preconditions.checkState(isSameRecording(activeRecording,
mActiveRecordingRecord));
break;
case INITIALIZING:
// Fall-through
case IDLING:
throw new IllegalStateException("Calling stop() while idling or "
+ "initializing is invalid.");
case PAUSED:
// Fall-through
case RECORDING:
setState(State.STOPPING);
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
ERROR_NONE, null));
break;
case ERROR:
// In an error state, the recording will already be finalized. Treat as a
// no-op in stop()
break;
}
}
if (pendingRecordingToFinalize != null) {
finalizePendingRecording(pendingRecordingToFinalize, ERROR_NO_VALID_DATA,
new RuntimeException("Recording was stopped before any data could be "
+ "produced."));
}
}
private void finalizePendingRecording(@NonNull RecordingRecord recordingToFinalize,
@VideoRecordError int error, @Nullable Throwable cause) {
recordingToFinalize.updateVideoRecordEvent(
VideoRecordEvent.finalizeWithError(
recordingToFinalize.getOutputOptions(),
RecordingStats.of(/*duration=*/0L,
/*bytes=*/0L,
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause)),
OutputResults.of(Uri.EMPTY),
error,
cause));
}
/**
* Resets the state on the sequential executor for a new recording.
*
* <p>If a recording is in progress, it will be stopped asynchronously and reset once it has
* been finalized.
*
* <p>If there is a recording in progress, reset() will stop the recording and rely on the
* recording's onRecordingFinalized() to actually release resources.
*/
@ExecutedBy("mSequentialExecutor")
void reset() {
boolean shouldReset = false;
boolean shouldStop = false;
synchronized (mLock) {
switch (mState) {
case PENDING_RECORDING:
// Fall-through
case PENDING_PAUSED:
// Fall-through
mSurfaceRequested = false;
shouldReset = true;
updateNonPendingState(State.RESETTING);
break;
case INITIALIZING:
// Fall-through
case ERROR:
// Fall-through
case IDLING:
setState(State.INITIALIZING);
mSurfaceRequested = false;
shouldReset = true;
break;
case PAUSED:
// Fall-through
case RECORDING:
if (mActiveRecordingRecord != mInProgressRecording) {
throw new AssertionError("In-progress recording does not match the active"
+ " recording. Unable to reset encoder.");
}
// If there's an active recording, stop it first then release the resources
// at onRecordingFinalized().
setState(State.RESETTING);
shouldStop = true;
break;
case STOPPING:
// Already stopping. Set state to RESETTING so resources will be released once
// onRecordingFinalized() runs.
setState(State.RESETTING);
// Fall-through
case RESETTING:
// No-Op, the Recorder is already being reset.
break;
}
}
// These calls must not be posted to the executor to ensure they are executed inline on
// the sequential executor and the state changes above are correctly handled.
if (shouldReset) {
resetInternal();
} else if (shouldStop) {
stopInternal(mInProgressRecording, ERROR_NONE, null);
}
}
@ExecutedBy("mSequentialExecutor")
private void initializeInternal(SurfaceRequest surfaceRequest) {
if (mSurface != null) {
// The video encoder has already be created, providing the surface directly.
surfaceRequest.provideSurface(mSurface, mSequentialExecutor, (result) -> {
Surface resultSurface = result.getSurface();
// The latest surface will be released by the encoder when encoder is released.
if (mSurface != resultSurface) {
resultSurface.release();
}
mSurface = null;
reset();
});
onInitialized();
} else {
setupVideo(surfaceRequest);
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
(transformationInfo) -> mSurfaceTransformationInfo =
transformationInfo);
}
}
@ExecutedBy("mSequentialExecutor")
private void onInitialized() {
RecordingRecord recordingToStart = null;
boolean startRecordingPaused = false;
synchronized (mLock) {
switch (mState) {
case IDLING:
// Fall-through
case RECORDING:
// Fall-through
case PAUSED:
// Fall-through
case RESETTING:
// Fall-through
case STOPPING:
throw new AssertionError(
"Incorrectly invoke onInitialized() in state " + mState);
case INITIALIZING:
setState(State.IDLING);
break;
case ERROR:
Logger.e(TAG,
"onInitialized() was invoked when the Recorder had encountered error "
+ mErrorCause);
break;
case PENDING_PAUSED:
startRecordingPaused = true;
// Fall through
case PENDING_RECORDING:
recordingToStart = makePendingRecordingActiveLocked(mState);
break;
}
}
if (recordingToStart != null) {
// Start new active recording inline on sequential executor (but unlocked).
startActiveRecording(recordingToStart, startRecordingPaused);
}
}
@NonNull
private MediaSpec composeRecorderMediaSpec(@NonNull MediaSpec mediaSpec) {
MediaSpec.Builder mediaSpecBuilder = mediaSpec.toBuilder();
if (mediaSpec.getOutputFormat() == MediaSpec.OUTPUT_FORMAT_AUTO) {
mediaSpecBuilder.setOutputFormat(MEDIA_SPEC_DEFAULT.getOutputFormat());
}
// Append default audio configurations
AudioSpec audioSpec = mediaSpec.getAudioSpec();
if (audioSpec.getSourceFormat() == AudioSpec.SOURCE_FORMAT_AUTO) {
mediaSpecBuilder.configureAudio(
builder -> builder.setSourceFormat(AUDIO_SPEC_DEFAULT.getSourceFormat()));
}
if (audioSpec.getSource() == AudioSpec.SOURCE_AUTO) {
mediaSpecBuilder.configureAudio(
builder -> builder.setSource(AUDIO_SPEC_DEFAULT.getSource()));
}
if (audioSpec.getChannelCount() == AudioSpec.CHANNEL_COUNT_AUTO) {
mediaSpecBuilder.configureAudio(
builder -> builder.setChannelCount(AUDIO_SPEC_DEFAULT.getChannelCount()));
}
// Append default video configurations
VideoSpec videoSpec = mediaSpec.getVideoSpec();
if (videoSpec.getAspectRatio() == VideoSpec.ASPECT_RATIO_AUTO) {
mediaSpecBuilder.configureVideo(
builder -> builder.setAspectRatio(VIDEO_SPEC_DEFAULT.getAspectRatio()));
}
return mediaSpecBuilder.build();
}
private static boolean isSameRecording(@NonNull ActiveRecording activeRecording,
@Nullable RecordingRecord recordingRecord) {
if (recordingRecord == null) {
return false;
}
return activeRecording.getRecordingId() == recordingRecord.getRecordingId();
}
@ExecutedBy("mSequentialExecutor")
@NonNull
private AudioEncoderConfig composeAudioEncoderConfig(@NonNull MediaSpec mediaSpec) {
return AudioEncoderConfig.builder()
.setMimeType(MediaSpec.outputFormatToAudioMime(mediaSpec.getOutputFormat()))
.setBitrate(AUDIO_BITRATE_DEFAULT)
.setSampleRate(selectSampleRate(mediaSpec.getAudioSpec()))
.setChannelCount(mediaSpec.getAudioSpec().getChannelCount())
.build();
}
@ExecutedBy("mSequentialExecutor")
@NonNull
private VideoEncoderConfig composeVideoEncoderConfig(@NonNull MediaSpec mediaSpec,
@NonNull Size surfaceSize) {
return VideoEncoderConfig.builder()
.setMimeType(MediaSpec.outputFormatToVideoMime(mediaSpec.getOutputFormat()))
.setResolution(surfaceSize)
// TODO: Add mechanism to pick a value from the specified range and
// CamcorderProfile.
.setBitrate(VIDEO_BITRATE_DEFAULT)
.setFrameRate(VIDEO_FRAME_RATE_DEFAULT)
.setColorFormat(MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
.setIFrameInterval(VIDEO_INTRA_FRAME_INTERVAL_DEFAULT)
.build();
}
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
@ExecutedBy("mSequentialExecutor")
private void setupAudioIfNeeded(@NonNull RecordingRecord activeRecording) {
if (!activeRecording.hasAudioEnabled()) {
// Skip if audio is not enabled for the recording.
return;
}
if (!isAudioSupported()) {
throw new IllegalStateException("The Recorder doesn't support recording with audio");
}
if (mAudioInitialized) {
// Skip if audio has already been initialized.
return;
}
MediaSpec mediaSpec = getObservableData(mMediaSpec);
AudioEncoderConfig config = composeAudioEncoderConfig(mediaSpec);
try {
mAudioEncoder = new EncoderImpl(mExecutor, config);
} catch (InvalidConfigException e) {
Logger.e(TAG, "Unable to initialize audio encoder." + e);
onEncoderSetupError(e);
return;
}
Encoder.EncoderInput bufferProvider = mAudioEncoder.getInput();
if (!(bufferProvider instanceof Encoder.ByteBufferInput)) {
throw new AssertionError("The EncoderInput of audio isn't a ByteBufferInput.");
}
try {
mAudioSource = setupAudioSource((Encoder.ByteBufferInput) bufferProvider,
mediaSpec.getAudioSpec());
} catch (AudioSourceAccessException e) {
Logger.e(TAG, "Unable to create audio source." + e);
throw new AssertionError("Unable to create audio source.", e);
}
mAudioInitialized = true;
}
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
@NonNull
private AudioSource setupAudioSource(@NonNull BufferProvider<InputBuffer> bufferProvider,
@NonNull AudioSpec audioSpec) throws AudioSourceAccessException {
AudioSource audioSource = new AudioSource.Builder()
.setExecutor(CameraXExecutors.ioExecutor())
.setBufferProvider(bufferProvider)
.setAudioSource(audioSpec.getSource())
.setSampleRate(selectSampleRate(audioSpec))
.setChannelCount(audioSpec.getChannelCount())
.setAudioFormat(audioSpec.getSourceFormat())
.build();
audioSource.setAudioSourceCallback(mSequentialExecutor,
new AudioSource.AudioSourceCallback() {
@Override
public void onSilenced(boolean silenced) {
switch (mAudioState) {
case DISABLED:
// Fall-through
case ENCODER_ERROR:
// Fall-through
case INITIALIZING:
// No-op
break;
case ACTIVE:
if (silenced) {
mCachedAudioState = mAudioState;
setAudioState(AudioState.SOURCE_SILENCED);
mAudioErrorCause = new IllegalStateException("The audio "
+ "source has been silenced.");
updateInProgressStatusEvent();
}
break;
case SOURCE_SILENCED:
if (!silenced) {
setAudioState(mCachedAudioState);
mAudioErrorCause = null;
updateInProgressStatusEvent();
}
break;
}
}
@Override
public void onError(@NonNull Throwable throwable) {
if (throwable instanceof AudioSourceAccessException) {
setAudioState(AudioState.DISABLED);
updateInProgressStatusEvent();
}
}
});
return audioSource;
}
@ExecutedBy("mSequentialExecutor")
private int selectSampleRate(AudioSpec audioSpec) {
// The default sample rate should work on most devices. May consider throw an
// exception or have other way to notify users that the specified sample rate
// can not be satisfied.
int selectedSampleRate = AUDIO_SAMPLE_RATE_DEFAULT;
for (int sampleRate : AudioSource.COMMON_SAMPLE_RATES) {
if (audioSpec.getSampleRate().contains(sampleRate)) {
if (AudioSource.isSettingsSupported(sampleRate, audioSpec.getChannelCount(),
audioSpec.getSourceFormat())) {
// Choose the largest valid sample rate as the list has descending order.
selectedSampleRate = sampleRate;
break;
}
}
}
return selectedSampleRate;
}
@ExecutedBy("mSequentialExecutor")
private void setupVideo(@NonNull SurfaceRequest surfaceRequest) {
MediaSpec mediaSpec = getObservableData(mMediaSpec);
VideoEncoderConfig config = composeVideoEncoderConfig(mediaSpec,
surfaceRequest.getResolution());
try {
mVideoEncoder = new EncoderImpl(mExecutor, config);
} catch (InvalidConfigException e) {
surfaceRequest.willNotProvideSurface();
Logger.e(TAG, "Unable to initialize video encoder." + e);
onEncoderSetupError(e);
return;
}
Encoder.EncoderInput encoderInput = mVideoEncoder.getInput();
if (!(encoderInput instanceof Encoder.SurfaceInput)) {
throw new AssertionError("The EncoderInput of video isn't a SurfaceInput.");
}
((Encoder.SurfaceInput) encoderInput).setOnSurfaceUpdateListener(
mSequentialExecutor,
surface -> {
mSurface = surface;
surfaceRequest.provideSurface(surface, mSequentialExecutor, (result) -> {
Surface resultSurface = result.getSurface();
// The latest surface will be released by the encoder when encoder is
// released.
if (mSurface != resultSurface) {
resultSurface.release();
}
mSurface = null;
reset();
});
onInitialized();
});
}
@ExecutedBy("mSequentialExecutor")
private void onEncoderSetupError(@Nullable Throwable cause) {
RecordingRecord pendingRecordingToFinalize = null;
synchronized (mLock) {
switch (mState) {
case PENDING_PAUSED:
// Fall-through
case PENDING_RECORDING:
pendingRecordingToFinalize = mPendingRecordingRecord;
mPendingRecordingRecord = null;
// Fall-through
case INITIALIZING:
setState(State.ERROR);
mErrorCause = cause;
break;
case ERROR:
// Already in an error state. Ignore new error.
break;
case PAUSED:
// Fall-through
case RECORDING:
// Fall-through
case IDLING:
// Fall-through
case RESETTING:
// Fall-through
case STOPPING:
throw new AssertionError("Encountered encoder setup error while in unexpected"
+ " state " + mState + ": " + cause);
}
}
if (pendingRecordingToFinalize != null) {
finalizePendingRecording(pendingRecordingToFinalize, ERROR_RECORDER_ERROR, cause);
}
}
@ExecutedBy("mSequentialExecutor")
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
void setupAndStartMediaMuxer(@NonNull RecordingRecord recordingToStart) {
if (mMediaMuxer != null) {
throw new AssertionError("Unable to set up media muxer when one already exists.");
}
if (isAudioEnabled() && mPendingFirstAudioData == null) {
throw new AssertionError("Audio is enabled but no audio sample is ready. Cannot start"
+ " media muxer.");
}
if (mPendingFirstVideoData == null) {
throw new AssertionError("Media muxer cannot be started without an encoded video "
+ "frame.");
}
try (EncodedData videoDataToWrite = mPendingFirstVideoData; EncodedData audioDataToWrite =
mPendingFirstAudioData) {
mPendingFirstVideoData = null;
mPendingFirstAudioData = null;
// Make sure we can write the first audio and video data without hitting the file size
// limit. Otherwise we will be left with a malformed (empty) track on stop.
long firstDataSize = videoDataToWrite.size();
if (audioDataToWrite != null) {
firstDataSize += audioDataToWrite.size();
}
if (mFileSizeLimitInBytes != OutputOptions.FILE_SIZE_UNLIMITED
&& firstDataSize > mFileSizeLimitInBytes) {
Logger.d(TAG,
String.format("Initial data exceeds file size limit %d > %d", firstDataSize,
mFileSizeLimitInBytes));
onInProgressRecordingInternalError(recordingToStart,
ERROR_FILE_SIZE_LIMIT_REACHED, null);
return;
}
try {
setupMediaMuxer(recordingToStart.getOutputOptions());
} catch (IOException e) {
onInProgressRecordingInternalError(recordingToStart, ERROR_INVALID_OUTPUT_OPTIONS,
e);
return;
}
mVideoTrackIndex = mMediaMuxer.addTrack(mVideoOutputConfig.getMediaFormat());
if (isAudioEnabled()) {
mAudioTrackIndex = mMediaMuxer.addTrack(mAudioOutputConfig.getMediaFormat());
}
mMediaMuxer.start();
// Write first data to ensure tracks are not empty
writeVideoData(videoDataToWrite, recordingToStart);
if (audioDataToWrite != null) {
writeAudioData(audioDataToWrite, recordingToStart);
}
}
}
@SuppressLint("WrongConstant")
@ExecutedBy("mSequentialExecutor")
private void setupMediaMuxer(@NonNull OutputOptions options) throws IOException {
int muxerOutputFormat = MediaSpec.outputFormatToMuxerFormat(
getObservableData(mMediaSpec).getOutputFormat());
if (options instanceof FileOutputOptions) {
FileOutputOptions fileOutputOptions = (FileOutputOptions) options;
File file = fileOutputOptions.getFile();
if (!OutputUtil.createParentFolder(file)) {
Logger.w(TAG, "Failed to create folder for " + file.getAbsolutePath());
}
mMediaMuxer = new MediaMuxer(file.getAbsolutePath(), muxerOutputFormat);
mOutputUri = Uri.fromFile(file);
} else if (options instanceof FileDescriptorOutputOptions) {
FileDescriptorOutputOptions fileDescriptorOutputOptions =
(FileDescriptorOutputOptions) options;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
mMediaMuxer = Api26Impl.createMediaMuxer(
fileDescriptorOutputOptions.getParcelFileDescriptor()
.getFileDescriptor(), muxerOutputFormat);
} else {
throw new IOException(
"MediaMuxer doesn't accept FileDescriptor as output destination.");
}
} else if (options instanceof MediaStoreOutputOptions) {
MediaStoreOutputOptions mediaStoreOutputOptions = (MediaStoreOutputOptions) options;
ContentValues contentValues =
new ContentValues(mediaStoreOutputOptions.getContentValues());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Toggle on pending status for the video file.
contentValues.put(MediaStore.Video.Media.IS_PENDING, PENDING);
}
Uri outputUri = mediaStoreOutputOptions.getContentResolver().insert(
mediaStoreOutputOptions.getCollection(), contentValues);
if (outputUri == null) {
throw new IOException("Unable to create MediaStore entry.");
}
mOutputUri = outputUri; // Guarantee mOutputUri is non-null value.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
String path =
OutputUtil.getAbsolutePathFromUri(
mediaStoreOutputOptions.getContentResolver(),
mOutputUri, MEDIA_COLUMN);
if (path == null) {
throw new IOException("Unable to get path from uri " + mOutputUri);
}
if (!OutputUtil.createParentFolder(new File(path))) {
Logger.w(TAG, "Failed to create folder for " + path);
}
mMediaMuxer = new MediaMuxer(path, muxerOutputFormat);
} else {
ParcelFileDescriptor fileDescriptor =
mediaStoreOutputOptions.getContentResolver().openFileDescriptor(
mOutputUri, "rw");
mMediaMuxer = Api26Impl.createMediaMuxer(fileDescriptor.getFileDescriptor(),
muxerOutputFormat);
fileDescriptor.close();
}
} else {
throw new AssertionError(
"Invalid output options type: " + options.getClass().getSimpleName());
}
// TODO: Add more metadata to MediaMuxer, e.g. location information.
if (mSurfaceTransformationInfo != null) {
mMediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees());
}
}
@SuppressLint("MissingPermission")
@ExecutedBy("mSequentialExecutor")
private void startInternal(@NonNull RecordingRecord recordingToStart) {
if (mInProgressRecording != null) {
throw new AssertionError("Attempted to start a new recording while another was in "
+ "progress.");
}
if (recordingToStart.getOutputOptions().getFileSizeLimit() > 0) {
// Use %95 of the given file size limit as the criteria, which refers to the
// MPEG4Writer.cpp in libstagefright.
mFileSizeLimitInBytes = Math.round(
recordingToStart.getOutputOptions().getFileSizeLimit() * 0.95);
Logger.d(TAG, "File size limit in bytes: " + mFileSizeLimitInBytes);
} else {
mFileSizeLimitInBytes = OutputOptions.FILE_SIZE_UNLIMITED;
}
setupAudioIfNeeded(recordingToStart);
mInProgressRecording = recordingToStart;
if (mAudioState == AudioState.INITIALIZING) {
setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.ACTIVE
: AudioState.DISABLED);
}
initEncoderCallbacks(recordingToStart);
if (isAudioEnabled()) {
mAudioSource.start();
mAudioEncoder.start();
}
mVideoEncoder.start();
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.start(
mInProgressRecording.getOutputOptions(),
getInProgressRecordingStats()));
}
@ExecutedBy("mSequentialExecutor")
private void initEncoderCallbacks(@NonNull RecordingRecord recordingToStart) {
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
completer -> {
mVideoEncoder.setEncoderCallback(new EncoderCallback() {
@ExecutedBy("mSequentialExecutor")
@Override
public void onEncodeStart() {
// No-op.
}
@ExecutedBy("mSequentialExecutor")
@Override
public void onEncodeStop() {
completer.set(null);
}
@ExecutedBy("mSequentialExecutor")
@Override
public void onEncodeError(@NonNull EncodeException e) {
completer.setException(e);
}
@ExecutedBy("mSequentialExecutor")
@Override
public void onEncodedData(@NonNull EncodedData encodedData) {
// If the media muxer doesn't yet exist, we may need to create and
// start it. Otherwise we can write the data.
if (mMediaMuxer == null) {
if (!mInProgressRecordingStopping) {
// Clear any previously pending video data since we now
// have newer data.
boolean cachedDataDropped = false;
if (mPendingFirstVideoData != null) {
cachedDataDropped = true;
mPendingFirstVideoData.close();
mPendingFirstVideoData = null;
}
if (encodedData.isKeyFrame()) {
// We have a keyframe. Cache it in case we need to wait
// for audio data.
mPendingFirstVideoData = encodedData;
// If first pending audio data exists or audio is
// disabled, we can start the muxer.
if (!isAudioEnabled() || mPendingFirstAudioData != null) {
Logger.d(TAG, "Received video keyframe. Starting "
+ "muxer...");
setupAndStartMediaMuxer(recordingToStart);
} else {
if (cachedDataDropped) {
Logger.d(TAG, "Replaced cached video keyframe "
+ "with newer keyframe.");
} else {
Logger.d(TAG, "Cached video keyframe while we wait "
+ "for first audio sample before starting "
+ "muxer.");
}
}
} else {
// If the video data is not a key frame,
// MediaMuxer#writeSampleData will drop it. It will
// cause incorrect estimated record bytes and should
// be dropped.
if (cachedDataDropped) {
Logger.d(TAG, "Dropped cached keyframe since we have "
+ "new video data and have not yet received "
+ "audio data.");
}
Logger.d(TAG, "Dropped video data since muxer has not yet "
+ "started and data is not a keyframe.");
mVideoEncoder.requestKeyFrame();
encodedData.close();
}
} else {
// Recording is stopping before muxer has been started.
Logger.d(TAG, "Drop video data since recording is stopping.");
encodedData.close();
}
} else {
// MediaMuxer is already started, write the data.
try (EncodedData videoDataToWrite = encodedData) {
writeVideoData(videoDataToWrite, recordingToStart);
}
}
}
@ExecutedBy("mSequentialExecutor")
@Override
public void onOutputConfigUpdate(@NonNull OutputConfig outputConfig) {
mVideoOutputConfig = outputConfig;
}
}, mSequentialExecutor);
return "videoEncodingFuture";
}));
if (isAudioEnabled()) {
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
completer -> {
mAudioEncoder.setEncoderCallback(new EncoderCallback() {
@ExecutedBy("mSequentialExecutor")
@Override
public void onEncodeStart() {
// No-op.
}
@ExecutedBy("mSequentialExecutor")
@Override
public void onEncodeStop() {
completer.set(null);
}
@ExecutedBy("mSequentialExecutor")
@Override
public void onEncodeError(@NonNull EncodeException e) {
// If the audio encoder encounters error, update the status event
// to notify users. Then continue recording without audio data.
setAudioState(AudioState.ENCODER_ERROR);
mAudioErrorCause = e;
updateInProgressStatusEvent();
completer.set(null);
}
@ExecutedBy("mSequentialExecutor")
@Override
public void onEncodedData(@NonNull EncodedData encodedData) {
if (mAudioState == AudioState.DISABLED) {
throw new AssertionError(
"Audio is not enabled but audio encoded data is "
+ "produced.");
}
// If the media muxer doesn't yet exist, we may need to create and
// start it. Otherwise we can write the data.
if (mMediaMuxer == null) {
if (!mInProgressRecordingStopping) {
boolean cachedDataDropped = false;
if (mPendingFirstAudioData != null) {
cachedDataDropped = true;
mPendingFirstAudioData.close();
mPendingFirstAudioData = null;
}
mPendingFirstAudioData = encodedData;
if (mPendingFirstVideoData != null) {
// Both audio and data are ready. Start the muxer.
Logger.d(TAG, "Received audio data. Starting muxer...");
setupAndStartMediaMuxer(recordingToStart);
} else {
if (cachedDataDropped) {
Logger.d(TAG, "Replaced cached audio data with "
+ "newer data.");
} else {
Logger.d(TAG, "Cached audio data while we wait for "
+ "video keyframe before starting muxer.");
}
}
} else {
// Recording is stopping before muxer has been started.
Logger.d(TAG,
"Drop audio data since recording is stopping.");
encodedData.close();
}
} else {
try (EncodedData audioDataToWrite = encodedData) {
writeAudioData(audioDataToWrite, recordingToStart);
}
}
}
@ExecutedBy("mSequentialExecutor")
@Override
public void onOutputConfigUpdate(@NonNull OutputConfig outputConfig) {
mAudioOutputConfig = outputConfig;
}
}, mSequentialExecutor);
return "audioEncodingFuture";
}));
}
Futures.addCallback(Futures.allAsList(mEncodingFutures),
new FutureCallback<List<Void>>() {
@Override
public void onSuccess(@Nullable List<Void> result) {
finalizeInProgressRecording(mRecordingStopError, mRecordingStopErrorCause);
}
@Override
public void onFailure(Throwable t) {
finalizeInProgressRecording(ERROR_ENCODING_FAILED, t);
}
},
// Can use direct executor since completers are always completed on sequential
// executor.
CameraXExecutors.directExecutor());
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
void writeVideoData(@NonNull EncodedData encodedData,
@NonNull RecordingRecord recording) {
if (mVideoTrackIndex == null) {
// Throw an exception if the data comes before the track is added.
throw new AssertionError(
"Video data comes before the track is added to MediaMuxer.");
}
long newRecordingBytes = mRecordingBytes + encodedData.size();
if (mFileSizeLimitInBytes != OutputOptions.FILE_SIZE_UNLIMITED
&& newRecordingBytes > mFileSizeLimitInBytes) {
Logger.d(TAG,
String.format("Reach file size limit %d > %d", newRecordingBytes,
mFileSizeLimitInBytes));
onInProgressRecordingInternalError(recording, ERROR_FILE_SIZE_LIMIT_REACHED, null);
return;
}
mMediaMuxer.writeSampleData(mVideoTrackIndex, encodedData.getByteBuffer(),
encodedData.getBufferInfo());
mRecordingBytes = newRecordingBytes;
if (mFirstRecordingVideoDataTimeUs == 0L) {
mFirstRecordingVideoDataTimeUs = encodedData.getPresentationTimeUs();
}
mRecordingDurationNs = TimeUnit.MICROSECONDS.toNanos(
encodedData.getPresentationTimeUs() - mFirstRecordingVideoDataTimeUs);
updateInProgressStatusEvent();
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
void writeAudioData(@NonNull EncodedData encodedData,
@NonNull RecordingRecord recording) {
long newRecordingBytes = mRecordingBytes + encodedData.size();
if (mFileSizeLimitInBytes != OutputOptions.FILE_SIZE_UNLIMITED
&& newRecordingBytes > mFileSizeLimitInBytes) {
Logger.d(TAG,
String.format("Reach file size limit %d > %d",
newRecordingBytes,
mFileSizeLimitInBytes));
onInProgressRecordingInternalError(recording, ERROR_FILE_SIZE_LIMIT_REACHED, null);
return;
}
mMediaMuxer.writeSampleData(mAudioTrackIndex,
encodedData.getByteBuffer(),
encodedData.getBufferInfo());
mRecordingBytes = newRecordingBytes;
}
@ExecutedBy("mSequentialExecutor")
private void pauseInternal(@NonNull RecordingRecord recordingToPause) {
// Only pause recording if recording is in-progress and it is not stopping.
if (mInProgressRecording == recordingToPause && !mInProgressRecordingStopping) {
if (isAudioEnabled()) {
mAudioEncoder.pause();
}
mVideoEncoder.pause();
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.pause(
mInProgressRecording.getOutputOptions(),
getInProgressRecordingStats()));
}
}
@ExecutedBy("mSequentialExecutor")
private void resumeInternal(@NonNull RecordingRecord recordingToResume) {
// Only resume recording if recording is in-progress and it is not stopping.
if (mInProgressRecording == recordingToResume && !mInProgressRecordingStopping) {
if (isAudioEnabled()) {
mAudioEncoder.start();
}
mVideoEncoder.start();
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume(
mInProgressRecording.getOutputOptions(),
getInProgressRecordingStats()));
}
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ExecutedBy("mSequentialExecutor")
void stopInternal(@NonNull RecordingRecord recordingToStop, @VideoRecordError int stopError,
@Nullable Throwable errorCause) {
// Only stop recording if recording is in-progress and it is not already stopping.
if (mInProgressRecording == recordingToStop && !mInProgressRecordingStopping) {
mInProgressRecordingStopping = true;
mRecordingStopError = stopError;
mRecordingStopErrorCause = errorCause;
if (isAudioEnabled()) {
if (mPendingFirstAudioData != null) {
mPendingFirstAudioData.close();
mPendingFirstAudioData = null;
}
mAudioEncoder.stop();
}
if (mPendingFirstVideoData != null) {
mPendingFirstVideoData.close();
mPendingFirstVideoData = null;
}
mVideoEncoder.stop();
}
}
@ExecutedBy("mSequentialExecutor")
private void resetInternal() {
if (mAudioEncoder != null) {
mAudioEncoder.release();
mAudioEncoder = null;
mAudioOutputConfig = null;
}
if (mVideoEncoder != null) {
mVideoEncoder.release();
mVideoEncoder = null;
mVideoOutputConfig = null;
}
if (mAudioSource != null) {
mAudioSource.release();
mAudioSource = null;
}
mAudioInitialized = false;
}
private int internalAudioStateToAudioStatsState(@NonNull AudioState audioState) {
switch (audioState) {
case DISABLED:
return AudioStats.AUDIO_STATE_DISABLED;
case INITIALIZING:
// Fall-through
case ACTIVE:
return AudioStats.AUDIO_STATE_ACTIVE;
case SOURCE_SILENCED:
return AudioStats.AUDIO_STATE_SOURCE_SILENCED;
case ENCODER_ERROR:
return AudioStats.AUDIO_STATE_ENCODER_ERROR;
}
// Should not reach.
throw new AssertionError("Invalid internal audio state: " + audioState);
}
@NonNull
private StreamState internalStateToStreamState(@NonNull State state) {
// Stopping state should be treated as inactive on certain chipsets. See b/196039619.
DeactivateEncoderSurfaceBeforeStopEncoderQuirk quirk =
DeviceQuirks.get(DeactivateEncoderSurfaceBeforeStopEncoderQuirk.class);
// TODO(b/197047288): For devices that have the above quirk, wait for a signal that the
// surface is no longer in use before stopping the video encoder.
return state == State.RECORDING || (state == State.STOPPING && quirk == null)
? StreamState.ACTIVE : StreamState.INACTIVE;
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ExecutedBy("mSequentialExecutor")
boolean isAudioEnabled() {
return mAudioState != AudioState.DISABLED && mAudioState != AudioState.ENCODER_ERROR;
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ExecutedBy("mSequentialExecutor")
void finalizeInProgressRecording(@VideoRecordError int error, @Nullable Throwable throwable) {
if (mInProgressRecording == null) {
throw new AssertionError("Attempted to finalize in-progress recording, but no "
+ "recording is in progress.");
}
int errorToSend = error;
if (mMediaMuxer != null) {
try {
mMediaMuxer.stop();
mMediaMuxer.release();
} catch (IllegalStateException e) {
Logger.e(TAG, "MediaMuxer failed to stop or release with error: " + e.getMessage());
if (errorToSend == ERROR_NONE) {
errorToSend = ERROR_UNKNOWN;
}
}
mMediaMuxer = null;
} else if (errorToSend == ERROR_NONE) {
// Muxer was never started, so recording has no data.
errorToSend = ERROR_NO_VALID_DATA;
}
OutputOptions outputOptions = mInProgressRecording.getOutputOptions();
RecordingStats stats = getInProgressRecordingStats();
mInProgressRecording.finalizeOutputFile(mOutputUri);
OutputResults outputResults = OutputResults.of(mOutputUri);
mInProgressRecording.updateVideoRecordEvent(errorToSend == ERROR_NONE
? VideoRecordEvent.finalize(
outputOptions,
stats,
outputResults)
: VideoRecordEvent.finalizeWithError(
outputOptions,
stats,
outputResults,
errorToSend,
throwable));
RecordingRecord finalizedRecording = mInProgressRecording;
mInProgressRecording = null;
mInProgressRecordingStopping = false;
mAudioTrackIndex = null;
mVideoTrackIndex = null;
mEncodingFutures.clear();
mOutputUri = Uri.EMPTY;
mRecordingBytes = 0L;
mRecordingDurationNs = 0L;
mFirstRecordingVideoDataTimeUs = 0L;
mRecordingStopError = ERROR_UNKNOWN;
mRecordingStopErrorCause = null;
mAudioErrorCause = null;
onRecordingFinalized(finalizedRecording);
}
@ExecutedBy("mSequentialExecutor")
private void onRecordingFinalized(@NonNull RecordingRecord finalizedRecording) {
boolean needsReset = false;
boolean startRecordingPaused = false;
RecordingRecord recordingToStart = null;
synchronized (mLock) {
if (mActiveRecordingRecord != finalizedRecording) {
throw new AssertionError("Active recording did not match finalized recording on "
+ "finalize.");
}
mActiveRecordingRecord = null;
switch (mState) {
case RESETTING:
setState(State.INITIALIZING);
mSurfaceRequested = false;
needsReset = true;
break;
case PAUSED:
// Fall-through
case RECORDING:
// If finalized while in a RECORDING or PAUSED state, then the recording was
// likely finalized due to an error.
// Fall-through
case STOPPING:
setState(State.IDLING);
break;
case PENDING_PAUSED:
startRecordingPaused = true;
// Fall-through
case PENDING_RECORDING:
recordingToStart = makePendingRecordingActiveLocked(mState);
break;
case ERROR:
// Error state is non-recoverable. Nothing to do here.
break;
case IDLING:
// Fall-through
case INITIALIZING:
throw new AssertionError("Unexpected state on finalize of recording: "
+ mState);
}
}
// Perform required actions from state changes inline on sequential executor but unlocked.
if (needsReset) {
resetInternal();
} else if (recordingToStart != null) {
startActiveRecording(recordingToStart, startRecordingPaused);
}
}
@ExecutedBy("mSequentialExecutor")
void onInProgressRecordingInternalError(@NonNull RecordingRecord recording,
@VideoRecordError int error, @Nullable Throwable cause) {
if (recording != mInProgressRecording) {
throw new AssertionError("Internal error occurred on recording that is not the current "
+ "in-progress recording.");
}
boolean needsStop = false;
synchronized (mLock) {
switch (mState) {
case PAUSED:
// Fall-through
case RECORDING:
setState(State.STOPPING);
needsStop = true;
// Fall-through
case STOPPING:
// Fall-through
case RESETTING:
// Fall-through
case PENDING_RECORDING:
// Fall-through
case PENDING_PAUSED:
// Fall-through
if (recording != mActiveRecordingRecord) {
throw new AssertionError("Internal error occurred for recording but it is"
+ " not the active recording.");
}
break;
case INITIALIZING:
// Fall-through
case IDLING:
// Fall-through
case ERROR:
throw new AssertionError("In-progress recording error occurred while in "
+ "unexpected state: " + mState);
}
}
if (needsStop) {
stopInternal(recording, error, cause);
}
}
@ExecutedBy("mSequentialExecutor")
void tryServicePendingRecording() {
boolean startRecordingPaused = false;
RecordingRecord recordingToStart = null;
synchronized (mLock) {
switch (mState) {
case PENDING_PAUSED:
startRecordingPaused = true;
// Fall-through
case PENDING_RECORDING:
if (mActiveRecordingRecord != null) {
// Active recording is still finalizing. Pending recording will be
// serviced in onRecordingFinalized().
break;
}
recordingToStart = makePendingRecordingActiveLocked(mState);
break;
case INITIALIZING:
// Fall-through
case IDLING:
// Fall-through
case RECORDING:
// Fall-through
case PAUSED:
// Fall-through
case STOPPING:
// Fall-through
case RESETTING:
// Fall-through
case ERROR:
break;
}
}
if (recordingToStart != null) {
// Start new active recording inline on sequential executor (but unlocked).
startActiveRecording(recordingToStart, startRecordingPaused);
}
}
/**
* Makes the pending recording active and returns the new active recording.
*
* <p>This method will not actually start the recording. It is up to the caller to start the
* returned recording. However, the Recorder.State will be updated to reflect what the state
* should be after the recording is started. This allows the recording to be started when no
* longer under lock.
*/
@GuardedBy("mLock")
@NonNull
private RecordingRecord makePendingRecordingActiveLocked(@NonNull State state) {
boolean startRecordingPaused = false;
if (state == State.PENDING_PAUSED) {
startRecordingPaused = true;
} else if (state != State.PENDING_RECORDING) {
throw new AssertionError("makePendingRecordingActiveLocked() can only be called from "
+ "a pending state.");
}
if (mActiveRecordingRecord != null) {
throw new AssertionError("Cannot make pending recording active because another "
+ "recording is already active.");
}
if (mPendingRecordingRecord == null) {
throw new AssertionError("Pending recording should exist when in a PENDING"
+ " state.");
}
// Swap the pending recording to the active recording and start it
RecordingRecord recordingToStart = mActiveRecordingRecord = mPendingRecordingRecord;
mPendingRecordingRecord = null;
// Start recording if start() has been called before video encoder is setup.
if (startRecordingPaused) {
setState(State.PAUSED);
} else {
setState(State.RECORDING);
}
return recordingToStart;
}
/**
* Actually starts a recording on the sequential executor.
*
* <p>This is intended to be called while unlocked on the sequential executor. It should only
* be called immediately after a pending recording has just been made active. The recording
* passed to this method should be the newly-made-active recording.
*/
@ExecutedBy("mSequentialExecutor")
private void startActiveRecording(@NonNull RecordingRecord recordingToStart,
boolean startRecordingPaused) {
// Start pending recording inline since we are already on sequential executor.
startInternal(recordingToStart);
if (startRecordingPaused) {
pauseInternal(recordingToStart);
}
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ExecutedBy("mSequentialExecutor")
void updateInProgressStatusEvent() {
if (mInProgressRecording != null) {
mInProgressRecording.updateVideoRecordEvent(
VideoRecordEvent.status(
mInProgressRecording.getOutputOptions(),
getInProgressRecordingStats()));
}
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ExecutedBy("mSequentialExecutor")
@NonNull
RecordingStats getInProgressRecordingStats() {
return RecordingStats.of(mRecordingDurationNs, mRecordingBytes,
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause));
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
<T> T getObservableData(@NonNull StateObservable<T> observable) {
ListenableFuture<T> future = observable.fetchData();
try {
// A StateObservable always has a state available and the future got from fetchData()
// will complete immediately.
return future.get();
} catch (ExecutionException | InterruptedException e) {
throw new IllegalStateException(e);
}
}
boolean isAudioSupported() {
return getObservableData(mMediaSpec).getAudioSpec().getChannelCount()
!= AudioSpec.CHANNEL_COUNT_NONE;
}
@GuardedBy("mLock")
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
void setState(@NonNull State state) {
// If we're attempt to transition to the same state, then we likely have a logic error.
// All state transitions should be intentional, so throw an AssertionError here.
if (mState == state) {
throw new AssertionError("Attempted to transition to state " + state + ", but "
+ "Recorder is already in state " + state);
}
Logger.d(TAG, "Transitioning Recorder internal state: " + mState + " --> " + state);
// If we are transitioning from a non-pending state to a pending state, we need to store
// the non-pending state so we can transition back if the pending recording is stopped
// before it becomes active.
StreamState streamState = null;
if (PENDING_STATES.contains(state)) {
if (!PENDING_STATES.contains(mState)) {
if (!VALID_NON_PENDING_STATES_WHILE_PENDING.contains(mState)) {
throw new AssertionError(
"Invalid state transition. Should not be transitioning "
+ "to a PENDING state from state " + mState);
}
mNonPendingState = mState;
streamState = internalStateToStreamState(mNonPendingState);
}
} else if (mNonPendingState != null) {
// Transitioning out of a pending state. Clear the non-pending state.
mNonPendingState = null;
}
mState = state;
if (streamState == null) {
streamState = internalStateToStreamState(mState);
}
mStreamState.setState(streamState);
}
/**
* Updates the non-pending state while in a pending state.
*
* <p>If called from a non-pending state, an assertion error will be thrown.
*/
@GuardedBy("mLock")
private void updateNonPendingState(@NonNull State state) {
if (!PENDING_STATES.contains(mState)) {
throw new AssertionError("Can only updated non-pending state from a pending state, "
+ "but state is " + mState);
}
if (!VALID_NON_PENDING_STATES_WHILE_PENDING.contains(state)) {
throw new AssertionError(
"Invalid state transition. State is not a valid non-pending state while in a "
+ "pending state: " + state);
}
if (mNonPendingState != state) {
mNonPendingState = state;
mStreamState.setState(internalStateToStreamState(state));
}
}
/**
* Convenience for restoring the state to the non-pending state.
*
* <p>This is equivalent to calling setState(mNonPendingState), but performs a few safety
* checks. This can only be called while in a pending state.
*/
@GuardedBy("mLock")
private void restoreNonPendingState() {
if (!PENDING_STATES.contains(mState)) {
throw new AssertionError("Cannot restore non-pending state when in state " + mState);
}
setState(mNonPendingState);
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ExecutedBy("mSequentialExecutor")
void setAudioState(AudioState audioState) {
Logger.d(TAG, "Transitioning audio state: " + mAudioState + " --> " + audioState);
mAudioState = audioState;
}
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
@AutoValue
abstract static class RecordingRecord {
private final AtomicReference<Consumer<Uri>> mOutputFileFinalizer =
new AtomicReference<>(ignored -> {
/* no-op by default */
});
static RecordingRecord from(@NonNull PendingRecording pendingRecording, long recordingId) {
OutputOptions outputOptions = pendingRecording.getOutputOptions();
RecordingRecord recordingRecord = new AutoValue_Recorder_RecordingRecord(
outputOptions,
pendingRecording.getCallbackExecutor(),
pendingRecording.getEventListener(),
pendingRecording.isAudioEnabled(),
recordingId
);
if (outputOptions instanceof MediaStoreOutputOptions) {
MediaStoreOutputOptions mediaStoreOutputOptions =
(MediaStoreOutputOptions) outputOptions;
// TODO(b/201946954): Investigate whether we should add a setting to disable
// scan/update to allow users to perform it themselves.
Consumer<Uri> outputFileFinalizer;
if (Build.VERSION.SDK_INT >= 29) {
outputFileFinalizer = outputUri -> {
if (outputUri.equals(Uri.EMPTY)) {
return;
}
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.Video.Media.IS_PENDING, NOT_PENDING);
mediaStoreOutputOptions.getContentResolver().update(outputUri,
contentValues, null, null);
};
} else {
// Context will only be held in local scope of the consumer so it will not be
// retained after finalizeOutputFile() is called.
Context finalContext = pendingRecording.getApplicationContext();
outputFileFinalizer = outputUri -> {
if (outputUri.equals(Uri.EMPTY)) {
return;
}
String filePath = OutputUtil.getAbsolutePathFromUri(
mediaStoreOutputOptions.getContentResolver(), outputUri,
MEDIA_COLUMN);
if (filePath != null) {
// Use null mime type list to have MediaScanner derive mime type from
// extension
MediaScannerConnection.scanFile(finalContext,
new String[]{filePath}, /*mimeTypes=*/null, (path, uri) -> {
if (uri == null) {
Logger.e(TAG, String.format("File scanning operation "
+ "failed [path: %s]", path));
} else {
Logger.d(TAG, String.format("File scan completed "
+ "successfully [path: %s, URI: %s]", path,
uri));
}
});
} else {
Logger.d(TAG,
"Skipping media scanner scan. Unable to retrieve file path "
+ "from URI: " + outputUri);
}
};
}
recordingRecord.mOutputFileFinalizer.set(outputFileFinalizer);
}
return recordingRecord;
}
@NonNull
abstract OutputOptions getOutputOptions();
@Nullable
abstract Executor getCallbackExecutor();
@Nullable
abstract Consumer<VideoRecordEvent> getEventListener();
abstract boolean hasAudioEnabled();
abstract long getRecordingId();
/**
* Updates the recording status and callback to users.
*/
void updateVideoRecordEvent(@NonNull VideoRecordEvent event) {
Preconditions.checkState(Objects.equals(event.getOutputOptions(), getOutputOptions()),
"Attempted to update event listener with event from incorrect recording "
+ "[Recording: " + event.getOutputOptions() + ", Expected: "
+ getOutputOptions() + "]");
if (getCallbackExecutor() != null && getEventListener() != null) {
try {
getCallbackExecutor().execute(() -> getEventListener().accept(event));
} catch (RejectedExecutionException e) {
Logger.e(TAG, "The callback executor is invalid.", e);
}
}
}
/**
* Performs final operations required to prepare completed output file.
*
* <p>Output file finalization can only occur once. Any subsequent calls to this method
* will throw an {@link AssertionError}.
*
* @param uri The uri of the output file.
*/
void finalizeOutputFile(@NonNull Uri uri) {
Consumer<Uri> outputFileFinalizer = mOutputFileFinalizer.getAndSet(null);
if (outputFileFinalizer == null) {
throw new AssertionError(
"Output file has already been finalized for recording " + this);
}
outputFileFinalizer.accept(uri);
}
}
/**
* Builder class for {@link Recorder} objects.
*/
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
public static final class Builder {
private final MediaSpec.Builder mMediaSpecBuilder;
private Executor mExecutor = null;
/**
* Constructor for {@code Recorder.Builder}.
*
* <p>Creates a builder which is pre-populated with appropriate default configuration
* options.
*/
public Builder() {
mMediaSpecBuilder = MediaSpec.builder();
}
/**
* Sets the {@link Executor} that runs the Recorder background task.
*
* <p>The executor is used to run the Recorder tasks, the audio encoding and the video
* encoding. For the best performance, it's recommended to be an {@link Executor} that is
* capable of running at least two tasks concurrently, such as a
* {@link java.util.concurrent.ThreadPoolExecutor} backed by 2 or more threads.
*
* <p>If not set, the Recorder will be run on the IO executor internally managed by CameraX.
*/
@NonNull
public Builder setExecutor(@NonNull Executor executor) {
Preconditions.checkNotNull(executor, "The specified executor can't be null.");
mExecutor = executor;
return this;
}
// Usually users can use the CameraX predefined configuration for creating a recorder. We
// may see which options of MediaSpec to be exposed.
/**
* Sets the {@link QualitySelector} of this Recorder.
*
* <p>The provided quality selector is used to select the resolution of the recording
* depending on the resolutions supported by the camera and codec capabilities.
*
* <p>If no quality selector is provided, the default is
* {@link Recorder#DEFAULT_QUALITY_SELECTOR}.
*
* @see QualitySelector
*/
@NonNull
public Builder setQualitySelector(@NonNull QualitySelector qualitySelector) {
Preconditions.checkNotNull(qualitySelector,
"The specified quality selector can't be null.");
mMediaSpecBuilder.configureVideo(
builder -> builder.setQualitySelector(qualitySelector));
return this;
}
/**
* Sets the aspect ratio of this Recorder.
*/
@NonNull
Builder setAspectRatio(@AspectRatio.Ratio int aspectRatio) {
mMediaSpecBuilder.configureVideo(builder -> builder.setAspectRatio(aspectRatio));
return this;
}
/**
* Sets the audio source for recordings with audio enabled.
*
* <p>This will only set the source of audio for recordings, but audio must still be
* enabled on a per-recording basis with {@link PendingRecording#withAudioEnabled()}
* before starting the recording.
*
* @param source The audio source to use. One of {@link AudioSpec#SOURCE_AUTO} or
* {@link AudioSpec#SOURCE_CAMCORDER}. Default is
* {@link AudioSpec#SOURCE_AUTO}.
*/
@NonNull
Builder setAudioSource(@AudioSpec.Source int source) {
mMediaSpecBuilder.configureAudio(builder -> builder.setSource(source));
return this;
}
/**
* Builds the {@link Recorder} instance.
*
* <p>The {code build()} method can be called multiple times, generating a new
* {@link Recorder} instance each time. The returned instance is configured with the
* options set on this builder.
*/
@NonNull
public Recorder build() {
return new Recorder(mExecutor, mMediaSpecBuilder.build());
}
}
}