/* * 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.AudioStats.AUDIO_AMPLITUDE_NONE; import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED; 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 static androidx.camera.video.internal.DebugUtils.readableUs; import static androidx.camera.video.internal.config.AudioConfigUtil.resolveAudioEncoderConfig; import static androidx.camera.video.internal.config.AudioConfigUtil.resolveAudioMimeInfo; import static androidx.camera.video.internal.config.AudioConfigUtil.resolveAudioSettings; import android.Manifest; import android.annotation.SuppressLint; import android.content.ContentValues; import android.content.Context; import android.media.MediaRecorder; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; import android.util.Range; import android.util.Size; import android.view.Surface; import androidx.annotation.GuardedBy; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.RequiresPermission; import androidx.annotation.RestrictTo; import androidx.annotation.VisibleForTesting; import androidx.camera.core.AspectRatio; import androidx.camera.core.CameraInfo; import androidx.camera.core.DynamicRange; 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.Timebase; import androidx.camera.core.impl.annotation.ExecutedBy; import androidx.camera.core.impl.utils.CloseGuardHelper; 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.core.internal.utils.ArrayRingBuffer; import androidx.camera.core.internal.utils.RingBuffer; import androidx.camera.video.StreamInfo.StreamState; import androidx.camera.video.internal.VideoValidatedEncoderProfilesProxy; import androidx.camera.video.internal.audio.AudioSettings; import androidx.camera.video.internal.audio.AudioSource; import androidx.camera.video.internal.audio.AudioSourceAccessException; 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.compat.quirk.EncoderNotUsePersistentInputSurfaceQuirk; import androidx.camera.video.internal.config.AudioMimeInfo; import androidx.camera.video.internal.encoder.AudioEncoderConfig; import androidx.camera.video.internal.encoder.BufferCopiedEncodedData; 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.EncoderFactory; import androidx.camera.video.internal.encoder.SucklessEncoderImpl; import androidx.camera.video.internal.encoder.InvalidConfigException; import androidx.camera.video.internal.encoder.OutputConfig; import androidx.camera.video.internal.encoder.VideoEncoderInfo; 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.Arrays; 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.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; 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}. * *
A recorder can be used to save the video frames sent from the {@link VideoCapture} use case * in common recording formats such as MPEG4. * *
Usage example of setting up {@link VideoCapture} with a recorder as output: *
* 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* *videoCapture = VideoCapture.withOutput(new Recorder.Builder().build()); * * // Bind use cases to Fragment/Activity lifecycle * cameraProvider.bindToLifecycle(this, cameraSelector, preview, videoCapture); *
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 SucklessPendingRecording} class * then can be used to adjust per-recording settings and to start the recording. It also requires * passing a listener to {@link SucklessPendingRecording#start(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. * *
Attaching a single Recorder instance to multiple video sources at the same time may causes * unexpected behaviors and is not recommended. * *
A recorder can also capture and save audio alongside video. The audio must be explicitly * enabled with {@link SucklessPendingRecording#withAudioEnabled()} before starting the recording. * * @see VideoCapture#withOutput(VideoOutput) * @see SucklessPendingRecording */ @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java @SuppressLint("RestrictedApi") public final class SucklessRecorder implements VideoOutput { private static final String TAG = "Recorder"; enum State { /** * The Recorder is being configured. * *
The Recorder will reach this state whenever it is waiting for a surface request. */ CONFIGURING, /** * There's a recording waiting for being started. * *
The Recorder will reach this state whenever a recording can not be serviced * immediately. */ PENDING_RECORDING, /** * There's a recording waiting for being paused. * *
The Recorder will reach this state whenever a recording can not be serviced
* immediately.
*/
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,
/**
* The audio has been initialized and is waiting for a new recording to be started.
*/
IDLING,
/**
* Audio recording is disabled for the running recording.
*/
DISABLED,
/**
* Audio recording is enabled for the running recording.
*/
ENABLED,
/**
* The audio encoder encountered errors.
*/
ERROR_ENCODER,
/**
* The audio source encountered errors.
*/
ERROR_SOURCE,
}
/**
* The subset of states considered pending states.
*/
private static final Set 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 The default quality selector chooses a video quality suitable for recordings based on
* device and compatibility constraints. It is equivalent to:
* If the Recorder is already running a recording, an {@link IllegalStateException} will
* be thrown when calling this method.
*
* If the video encoder hasn't been setup with {@link #onSurfaceRequested(SurfaceRequest)}
* , the {@link SucklessPendingRecording} 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.
*
* 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 SucklessPendingRecording#withAudioEnabled() enabled} for the
* recording but
* {@link android.Manifest.permission#RECORD_AUDIO} is not
* granted.
*/
@NonNull
SucklessRecording start(@NonNull SucklessPendingRecording 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;
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 CONFIGURING:
// Fall-through
case ERROR:
// Fall-through
case IDLING:
if (mState == State.IDLING) {
Preconditions.checkState(
mActiveRecordingRecord == null
&& mPendingRecordingRecord == null,
"Expected recorder to be idle but a recording is either "
+ "pending or in progress.");
}
try {
RecordingRecord recordingRecord = RecordingRecord.from(pendingRecording,
recordingId);
recordingRecord.initializeRecording(
pendingRecording.getApplicationContext());
mPendingRecordingRecord = recordingRecord;
if (mState == State.IDLING) {
setState(State.PENDING_RECORDING);
mSequentialExecutor.execute(this::tryServicePendingRecording);
} else if (mState == State.ERROR) {
setState(State.PENDING_RECORDING);
// Retry initialization.
mSequentialExecutor.execute(() -> {
if (mLatestSurfaceRequest == null) {
throw new AssertionError(
"surface request is required to retry "
+ "initialization.");
}
configureInternal(mLatestSurfaceRequest, mVideoSourceTimebase);
});
} else {
setState(State.PENDING_RECORDING);
// The recording will automatically start once the initialization
// completes.
}
} catch (IOException e) {
error = ERROR_INVALID_OUTPUT_OPTIONS;
errorCause = e;
}
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 SucklessRecording.createFinalizedFrom(pendingRecording, recordingId);
}
return SucklessRecording.from(pendingRecording, recordingId);
}
void pause(@NonNull SucklessRecording activeRecording) {
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
activeRecording, mActiveRecordingRecord)) {
// If this Recording 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 CONFIGURING:
// 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 SucklessRecording activeRecording) {
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
activeRecording, mActiveRecordingRecord)) {
// If this Recording 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 CONFIGURING:
// 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 SucklessRecording activeRecording, @VideoRecordError int error,
@Nullable Throwable errorCause) {
RecordingRecord pendingRecordingToFinalize = null;
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
activeRecording, mActiveRecordingRecord)) {
// If this Recording 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 CONFIGURING:
// Fall-through
case IDLING:
throw new IllegalStateException("Calling stop() while idling or initializing "
+ "is invalid.");
case PAUSED:
// Fall-through
case RECORDING:
setState(State.STOPPING);
long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
explicitlyStopTimeUs, error, errorCause));
break;
case ERROR:
// In an error state, the recording will already be finalized. Treat as a
// no-op in stop()
break;
}
}
if (pendingRecordingToFinalize != null) {
if (error == VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED) {
Logger.e(TAG, "Recording was stopped due to recording being garbage collected "
+ "before any valid data has been produced.");
}
finalizePendingRecording(pendingRecordingToFinalize, ERROR_NO_VALID_DATA,
new RuntimeException("Recording was stopped before any data could be "
+ "produced.", errorCause));
}
}
void mute(@NonNull SucklessRecording activeRecording, boolean muted) {
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
activeRecording, mActiveRecordingRecord)) {
// If this Recording 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,
"mute() called on a recording that is no longer active: "
+ activeRecording.getOutputOptions());
return;
}
RecordingRecord finalRecordingRecord = isSameRecording(activeRecording,
mPendingRecordingRecord) ? mPendingRecordingRecord : mActiveRecordingRecord;
mSequentialExecutor.execute(() -> muteInternal(finalRecordingRecord, muted));
}
}
private void finalizePendingRecording(@NonNull RecordingRecord recordingToFinalize,
@VideoRecordError int error, @Nullable Throwable cause) {
recordingToFinalize.finalizeRecording(Uri.EMPTY);
recordingToFinalize.updateVideoRecordEvent(
VideoRecordEvent.finalizeWithError(
recordingToFinalize.getOutputOptions(),
RecordingStats.of(/*duration=*/0L,
/*bytes=*/0L,
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause,
AUDIO_AMPLITUDE_NONE)),
OutputResults.of(Uri.EMPTY),
error,
cause));
}
@ExecutedBy("mSequentialExecutor")
private void onSurfaceRequestedInternal(@NonNull SurfaceRequest request,
@NonNull Timebase timebase) {
if (mLatestSurfaceRequest != null && !mLatestSurfaceRequest.isServiced()) {
mLatestSurfaceRequest.willNotProvideSurface();
}
configureInternal(mLatestSurfaceRequest = request, mVideoSourceTimebase = timebase);
}
@ExecutedBy("mSequentialExecutor")
void onSourceStateChangedInternal(@NonNull SourceState newState) {
SourceState oldState = mSourceState;
mSourceState = newState;
if (oldState != newState) {
Logger.d(TAG, "Video source has transitioned to state: " + newState);
} else {
Logger.d(TAG, "Video source transitions to the same state: " + newState);
return;
}
if (newState == SourceState.INACTIVE) {
if (mActiveSurface == null) {
// If we're inactive and have no active surface, we'll reset the encoder directly.
// Otherwise, we'll wait for the active surface's surface request listener to
// reset the encoder.
requestReset(ERROR_SOURCE_INACTIVE, null, false);
} else {
// The source becomes inactive, the incoming new surface request has to be cached
// and be serviced after the Recorder is reset when receiving the previous
// surface request complete callback.
mNeedsResetBeforeNextStart = true;
if (mInProgressRecording != null && !mInProgressRecording.isPersistent()) {
// Stop the in progress recording with "source inactive" error if it's not a
// persistent recording.
onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE,
null);
}
}
} else if (newState == SourceState.ACTIVE_NON_STREAMING) {
// We are expecting the source to transition to NON_STREAMING state.
if (mSourceNonStreamingTimeout != null && mSourceNonStreamingTimeout.cancel(false)
&& mVideoEncoder != null) {
notifyEncoderSourceStopped(mVideoEncoder);
}
}
}
/**
* Requests the Recorder to be reset.
*
* If a recording is in progress, it will be stopped asynchronously and reset once it has
* been finalized.
*
* The Recorder is expected to be reset when there's no active surface. Otherwise, wait for
* the surface request complete callback first.
*/
@ExecutedBy("mSequentialExecutor")
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause,
boolean videoOnly) {
boolean shouldReset = false;
boolean shouldStop = false;
synchronized (mLock) {
switch (mState) {
case PENDING_RECORDING:
// Fall-through
case PENDING_PAUSED:
// Fall-through
shouldReset = true;
updateNonPendingState(State.RESETTING);
break;
case ERROR:
// Fall-through
case IDLING:
// Fall-through
case CONFIGURING:
shouldReset = true;
break;
case PAUSED:
// Fall-through
case RECORDING:
Preconditions.checkState(mInProgressRecording != null, "In-progress recording"
+ " shouldn't be null when in state " + mState);
if (mActiveRecordingRecord != mInProgressRecording) {
throw new AssertionError("In-progress recording does not match the active"
+ " recording. Unable to reset encoder.");
}
// If there's an active persistent recording, reset the Recorder directly.
// Otherwise, stop the recording first then release the Recorder at
// onRecordingFinalized().
if (isPersistentRecordingInProgress()) {
shouldReset = true;
} else {
shouldStop = true;
setState(State.RESETTING);
}
break;
case STOPPING:
// Already stopping. Set state to RESETTING so resources will be released once
// onRecordingFinalized() runs.
setState(State.RESETTING);
break;
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) {
if (videoOnly) {
resetVideo();
} else {
reset();
}
} else if (shouldStop) {
stopInternal(mInProgressRecording, Encoder.NO_TIMESTAMP, errorCode, errorCause);
}
}
@ExecutedBy("mSequentialExecutor")
private void configureInternal(@NonNull SurfaceRequest surfaceRequest,
@NonNull Timebase videoSourceTimebase) {
if (surfaceRequest.isServiced()) {
Logger.w(TAG, "Ignore the SurfaceRequest since it is already served.");
return;
}
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
(transformationInfo) -> mSourceTransformationInfo = transformationInfo);
Size surfaceSize = surfaceRequest.getResolution();
// Fetch and cache nearest encoder profiles, if one exists.
DynamicRange dynamicRange = surfaceRequest.getDynamicRange();
VideoCapabilities capabilities = getVideoCapabilities(
surfaceRequest.getCamera().getCameraInfo());
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize,
dynamicRange);
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
+ " for surface size " + surfaceSize);
if (highestSupportedQuality != Quality.NONE) {
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality,
dynamicRange);
if (mResolvedEncoderProfiles == null) {
throw new AssertionError("Camera advertised available quality but did not "
+ "produce EncoderProfiles for advertised quality.");
}
}
setupVideo(surfaceRequest, videoSourceTimebase);
}
@SuppressWarnings("ObjectToString")
@ExecutedBy("mSequentialExecutor")
private void setupVideo(@NonNull SurfaceRequest request, @NonNull Timebase timebase) {
safeToCloseVideoEncoder().addListener(() -> {
if (request.isServiced()
|| (mVideoEncoderSession.isConfiguredSurfaceRequest(request)
&& !isPersistentRecordingInProgress())) {
// Ignore the surface request if it's already serviced. Or the video encoder
// session is already configured, unless there's a persistent recording is running.
Logger.w(TAG, "Ignore the SurfaceRequest " + request + " isServiced: "
+ request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession
+ " has been configured with a persistent in-progress recording.");
return;
}
VideoEncoderSession videoEncoderSession =
new VideoEncoderSession(mVideoEncoderFactory, mSequentialExecutor, mExecutor);
MediaSpec mediaSpec = getObservableData(mMediaSpec);
ListenableFuture 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.
*
* 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 startRecording(@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,
mAudioAmplitude));
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
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;
mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(state),
mInProgressTransformationInfo));
}
}
/**
* Convenience for restoring the state to the non-pending state.
*
* 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(@NonNull AudioState audioState) {
Logger.d(TAG, "Transitioning audio state: " + mAudioState + " --> " + audioState);
mAudioState = audioState;
}
private static int supportedMuxerFormatOrDefaultFrom(
@Nullable VideoValidatedEncoderProfilesProxy profilesProxy, int defaultMuxerFormat) {
if (profilesProxy != null) {
switch (profilesProxy.getRecommendedFileFormat()) {
case MediaRecorder.OutputFormat.MPEG_4:
return android.media.MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4;
case MediaRecorder.OutputFormat.WEBM:
return android.media.MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM;
case MediaRecorder.OutputFormat.THREE_GPP:
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// MediaMuxer does not support 3GPP on pre-Android O(API 26) devices.
return android.media.MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4;
} else {
return android.media.MediaMuxer.OutputFormat.MUXER_OUTPUT_3GPP;
}
default:
break;
}
}
return defaultMuxerFormat;
}
/**
* Returns the {@link VideoCapabilities} of Recorder with respect to input camera information.
*
* {@link VideoCapabilities} provides methods to query supported dynamic ranges and
* qualities. This information can be used for things like checking if HDR is supported for
* configuring VideoCapture to record HDR video.
*
* @param cameraInfo info about the camera.
* @return VideoCapabilities with respect to the input camera info.
*/
@NonNull
public static VideoCapabilities getVideoCapabilities(@NonNull CameraInfo cameraInfo) {
return RecorderVideoCapabilities.from(cameraInfo);
}
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
@AutoValue
abstract static class RecordingRecord implements AutoCloseable {
private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
private final AtomicBoolean mInitialized = new AtomicBoolean(false);
private final AtomicReference An audio source can only be created once per recording, so subsequent calls to this
* method will throw an {@link AssertionError}.
*
* Calling this method when audio is not enabled for this recording will also throw an
* {@link AssertionError}.
*/
@NonNull
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
AudioSource performOneTimeAudioSourceCreation(
@NonNull AudioSettings settings, @NonNull Executor audioSourceExecutor)
throws AudioSourceAccessException {
if (!hasAudioEnabled()) {
throw new AssertionError("Recording does not have audio enabled. Unable to create"
+ " audio source for recording " + this);
}
AudioSourceSupplier audioSourceSupplier = mAudioSourceSupplier.getAndSet(null);
if (audioSourceSupplier == null) {
throw new AssertionError("One-time audio source creation has already occurred for"
+ " recording " + this);
}
return audioSourceSupplier.get(settings, audioSourceExecutor);
}
/**
* Creates a {@link MediaMuxer} for this recording.
*
* A media muxer can only be created once per recording, so subsequent calls to this
* method will throw an {@link AssertionError}.
*
* @param muxerOutputFormat the output file format.
* @param outputUriCreatedCallback A callback that will send the returned media muxer's
* output {@link Uri}. It will be {@link Uri#EMPTY} if the
* {@link #getOutputOptions() OutputOptions} is
* {@link FileDescriptorOutputOptions}.
* Note: This callback will be called inline.
* @return the media muxer.
* @throws IOException if the creation of the media mixer fails.
* @throws AssertionError if the recording is not initialized or subsequent calls to this
* method.
*/
@NonNull
MediaMuxer performOneTimeMediaMuxerCreation(int muxerOutputFormat,
@NonNull Consumer Recording finalization can only occur once. Any subsequent calls to this method or
* {@link #close()} will throw an {@link AssertionError}.
*
* Finalizing an uninitialized recording is no-op.
*
* @param uri The uri of the output file.
*/
void finalizeRecording(@NonNull Uri uri) {
if (!mInitialized.get()) {
return;
}
finalizeRecordingInternal(mRecordingFinalizer.getAndSet(null), uri);
}
void mute(boolean muted) {
mMuted.set(muted);
}
boolean isMuted() {
return mMuted.get();
}
/**
* Close this recording, as if calling {@link #finalizeRecording(Uri)} with parameter
* {@link Uri#EMPTY}.
*
* This method is equivalent to calling {@link #finalizeRecording(Uri)} with parameter
* {@link Uri#EMPTY}.
*
* Recording finalization can only occur once. Any subsequent calls to this method or
* {@link #finalizeRecording(Uri)} will throw an {@link AssertionError}.
*
* Closing an uninitialized recording is no-op.
*/
@Override
public void close() {
finalizeRecording(Uri.EMPTY);
}
@Override
@SuppressWarnings("GenericException") // super.finalize() throws Throwable
protected void finalize() throws Throwable {
try {
mCloseGuard.warnIfOpen();
Consumer 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.
*
* 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.
*
* 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.
*
* The provided quality selector is used to select the resolution of the recording
* depending on the resolutions supported by the camera and codec capabilities.
*
* If no quality selector is provided, the default is
* {@link Recorder#DEFAULT_QUALITY_SELECTOR}.
*
* {@link #setAspectRatio(int)} can be used with to specify the intended video aspect
* ratio.
*
* @see QualitySelector
* @see #setAspectRatio(int)
*/
@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 intended video encoding bitrate for recording.
*
* The target video encoding bitrate attempts to keep the actual video encoding
* bitrate close to the requested {@code bitrate}. Bitrate may vary during a recording
* depending on the scene
* being recorded.
*
* Additional checks will be performed on the requested {@code bitrate} to make sure the
* specified bitrate is applicable, and sometimes the passed bitrate will be changed
* internally to ensure the video recording can proceed smoothly based on the
* capabilities of the platform.
*
* This API only affects the video stream and should not be considered the
* target for the entire recording. The audio stream's bitrate is not affected by this API.
*
* If this method isn't called, an appropriate bitrate for normal video
* recording is selected by default. Only call this method if a custom bitrate is desired.
*
* @param bitrate the target video encoding bitrate in bits per second.
* @throws IllegalArgumentException if bitrate is 0 or less.
*/
@NonNull
public Builder setTargetVideoEncodingBitRate(@IntRange(from = 1) int bitrate) {
if (bitrate <= 0) {
throw new IllegalArgumentException("The requested target bitrate " + bitrate
+ " is not supported. Target bitrate must be greater than 0.");
}
mMediaSpecBuilder.configureVideo(
builder -> builder.setBitrate(new Range<>(bitrate, bitrate)));
return this;
}
/**
* Sets the video aspect ratio of this Recorder.
*
* The final video resolution will be based on the input aspect ratio and the
* QualitySelector in {@link #setQualitySelector(QualitySelector)}. Both settings will be
* respected. For example, if the aspect ratio is 4:3 and the preferred quality in
* QualitySelector is HD, then a HD quality resolution with 4:3 aspect ratio such as
* 1280x960 or 960x720 will be used. CameraX will choose an appropriate one depending on
* the resolutions supported by the camera and the codec capabilities. With this setting,
* no other aspect ratios (such as 16:9) will be used, nor any other qualities (such as
* UHD, FHD and SD). If no resolution with the settings can be found, it will fail to
* bind VideoCapture. Therefore, a recommended way is to provide a flexible
* QualitySelector if there is no specific video quality requirement, such as the setting
* in {@link Recorder#DEFAULT_QUALITY_SELECTOR}.
*
* The default value is {@link AspectRatio#RATIO_DEFAULT}. If no aspect ratio is set, the
* selected resolution will be based only on the QualitySelector.
*
* @param aspectRatio the aspect ratio. Possible values are {@link AspectRatio#RATIO_4_3}
* and {@link AspectRatio#RATIO_16_9}.
*
* @see #setQualitySelector(QualitySelector)
*/
@NonNull
public Builder setAspectRatio(@AspectRatio.Ratio int aspectRatio) {
mMediaSpecBuilder.configureVideo(builder -> builder.setAspectRatio(aspectRatio));
return this;
}
/**
* Sets the audio source for recordings with audio enabled.
*
* This will only set the source of audio for recordings, but audio must still be
* enabled on a per-recording basis with {@link SucklessPendingRecording#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;
}
@RestrictTo(RestrictTo.Scope.LIBRARY)
@NonNull
Builder setVideoEncoderFactory(@NonNull EncoderFactory videoEncoderFactory) {
mVideoEncoderFactory = videoEncoderFactory;
return this;
}
@RestrictTo(RestrictTo.Scope.LIBRARY)
@NonNull
Builder setAudioEncoderFactory(@NonNull EncoderFactory audioEncoderFactory) {
mAudioEncoderFactory = audioEncoderFactory;
return this;
}
/**
* Builds the {@link Recorder} instance.
*
* 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 SucklessRecorder build() {
return new SucklessRecorder(mExecutor, mMediaSpecBuilder.build(), mVideoEncoderFactory,
mAudioEncoderFactory);
}
}
}
{@code
* QualitySelector.fromOrderedList(Arrays.asList(Quality.FHD, Quality.HD, Quality.SD),
* FallbackStrategy.higherQualityOrLowerThan(Quality.FHD));
* }
*
* @see QualitySelector
*/
public static final QualitySelector DEFAULT_QUALITY_SELECTOR =
QualitySelector.fromOrderedList(Arrays.asList(Quality.FHD, Quality.HD, Quality.SD),
FallbackStrategy.higherQualityOrLowerThan(Quality.FHD));
private static final VideoSpec VIDEO_SPEC_DEFAULT =
VideoSpec.builder()
.setQualitySelector(DEFAULT_QUALITY_SELECTOR)
.setAspectRatio(AspectRatio.RATIO_DEFAULT)
.build();
private static final MediaSpec MEDIA_SPEC_DEFAULT =
MediaSpec.builder()
.setOutputFormat(MediaSpec.OUTPUT_FORMAT_AUTO)
.setVideoSpec(VIDEO_SPEC_DEFAULT)
.build();
@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 static final long SOURCE_NON_STREAMING_TIMEOUT_MS = 1000L;
// The audio data is expected to be less than 1 kB, the value of the cache size is used to limit
// the memory used within an acceptable range.
private static final int AUDIO_CACHE_SIZE = 60;
@VisibleForTesting
static final EncoderFactory DEFAULT_ENCODER_FACTORY = SucklessEncoderImpl::new;
private static final Executor AUDIO_EXECUTOR =
CameraXExecutors.newSequentialExecutor(CameraXExecutors.ioExecutor());
private final MutableStateObservable> listFuture = Futures.allAsList(mEncodingFutures);
if (!listFuture.isDone()) {
listFuture.cancel(true);
}
mEncodingFutures.clear();
}
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 (true) { // Let custom Muxers receive all frames
// 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()
|| !mPendingAudioRingBuffer.isEmpty()) {
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() && !videoOnly) {
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
completer -> {
Consumer
>() {
@Override
public void onSuccess(@Nullable List