Encoders improvements (#545)
* Create DeviceEncoders * Prefer hardware encoders, adjust size and bitrate * Do the same for video frame rate * Fix docs * Rename CameraView method * Fix DeviceEncoders comparator * Add tests * Fix tests * Fix tests again * Scale down based on encoder requirements * Add DeviceEncoders MODE_ values * Use DeviceEncoders for full videos as well * Fix small bugpull/564/head
parent
f48d1c17ba
commit
f2ea77ce79
@ -0,0 +1,224 @@ |
||||
package com.otaliastudios.cameraview.internal; |
||||
|
||||
|
||||
import android.media.MediaCodecInfo; |
||||
|
||||
import androidx.annotation.NonNull; |
||||
import androidx.test.ext.junit.runners.AndroidJUnit4; |
||||
import androidx.test.filters.MediumTest; |
||||
import androidx.test.rule.ActivityTestRule; |
||||
|
||||
import com.otaliastudios.cameraview.BaseTest; |
||||
import com.otaliastudios.cameraview.TestActivity; |
||||
import com.otaliastudios.cameraview.controls.Grid; |
||||
import com.otaliastudios.cameraview.size.AspectRatio; |
||||
import com.otaliastudios.cameraview.size.Size; |
||||
|
||||
import org.junit.After; |
||||
import org.junit.Before; |
||||
import org.junit.Rule; |
||||
import org.junit.Test; |
||||
import org.junit.runner.RunWith; |
||||
|
||||
import java.util.Arrays; |
||||
import java.util.List; |
||||
|
||||
import static org.junit.Assert.assertEquals; |
||||
import static org.junit.Assert.assertFalse; |
||||
import static org.junit.Assert.assertNotEquals; |
||||
import static org.junit.Assert.assertNotNull; |
||||
import static org.junit.Assert.assertNull; |
||||
import static org.junit.Assert.assertSame; |
||||
import static org.junit.Assert.assertTrue; |
||||
import static org.mockito.Mockito.mock; |
||||
import static org.mockito.Mockito.when; |
||||
|
||||
@RunWith(AndroidJUnit4.class) |
||||
@MediumTest |
||||
public class DeviceEncodersTest extends BaseTest { |
||||
|
||||
// This is guaranteed to work, see
|
||||
// https://developer.android.com/guide/topics/media/media-formats
|
||||
private final static Size GUARANTEED_SIZE = new Size(176, 144); |
||||
|
||||
private boolean enabled; |
||||
|
||||
@Before |
||||
public void setUp() { |
||||
enabled = DeviceEncoders.ENABLED; |
||||
} |
||||
|
||||
@After |
||||
public void tearDown() { |
||||
DeviceEncoders.ENABLED = enabled; |
||||
} |
||||
|
||||
@NonNull |
||||
private DeviceEncoders create() { |
||||
return new DeviceEncoders( |
||||
"video/avc", |
||||
"audio/mp4a-latm", |
||||
DeviceEncoders.MODE_TAKE_FIRST); |
||||
} |
||||
|
||||
@Test |
||||
public void testGetDeviceEncoders() { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
if (DeviceEncoders.ENABLED) { |
||||
List<MediaCodecInfo> infos = deviceEncoders.getDeviceEncoders(); |
||||
for (MediaCodecInfo info : infos) { |
||||
assertTrue(info.isEncoder()); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
public void testIsHardwareEncoder() { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
if (DeviceEncoders.ENABLED) { |
||||
assertFalse(deviceEncoders.isHardwareEncoder("OMX.google.encoder")); |
||||
assertTrue(deviceEncoders.isHardwareEncoder("OMX.other.encoder")); |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
public void testFindDeviceEncoder() { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
if (DeviceEncoders.ENABLED) { |
||||
List<MediaCodecInfo> allEncoders = deviceEncoders.getDeviceEncoders(); |
||||
MediaCodecInfo encoder = deviceEncoders.findDeviceEncoder(allEncoders, |
||||
"video/avc", DeviceEncoders.MODE_TAKE_FIRST); |
||||
assertNotNull(encoder); |
||||
List<String> encoderTypes = Arrays.asList(encoder.getSupportedTypes()); |
||||
assertTrue(encoderTypes.contains("video/avc")); |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
public void testGetVideoEncoder() { |
||||
if (DeviceEncoders.ENABLED) { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
assertNotNull(deviceEncoders.getVideoEncoder()); |
||||
} |
||||
|
||||
DeviceEncoders.ENABLED = false; |
||||
DeviceEncoders deviceEncoders = create(); |
||||
assertNull(deviceEncoders.getVideoEncoder()); |
||||
} |
||||
|
||||
@Test |
||||
public void testGetAudioEncoder() { |
||||
if (DeviceEncoders.ENABLED) { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
assertNotNull(deviceEncoders.getAudioEncoder()); |
||||
} |
||||
|
||||
DeviceEncoders.ENABLED = false; |
||||
DeviceEncoders deviceEncoders = create(); |
||||
assertNull(deviceEncoders.getAudioEncoder()); |
||||
} |
||||
|
||||
@Test |
||||
public void testGetSupportedVideoSize_disabled() { |
||||
DeviceEncoders.ENABLED = false; |
||||
DeviceEncoders deviceEncoders = create(); |
||||
Size input = new Size(GUARANTEED_SIZE.getWidth(), GUARANTEED_SIZE.getHeight()); |
||||
Size output = deviceEncoders.getSupportedVideoSize(input); |
||||
assertSame(input, output); |
||||
} |
||||
|
||||
@Test |
||||
public void testGetSupportedVideoSize_scalesDown() { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
if (DeviceEncoders.ENABLED) { |
||||
Size input = new Size( |
||||
GUARANTEED_SIZE.getWidth() * 1000, |
||||
GUARANTEED_SIZE.getHeight() * 1000); |
||||
try { |
||||
Size output = deviceEncoders.getSupportedVideoSize(input); |
||||
assertTrue(AspectRatio.of(input).matches(output, 0.01F)); |
||||
} catch (RuntimeException e) { |
||||
// The scaled down size happens to be not supported.
|
||||
// I see no way of testing this easily if we're not sure of supported ranges.
|
||||
// This depends highly on the alignment since scaling down, while keeping AR,
|
||||
// can change the alignment and require width / height changes.
|
||||
} |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
public void testGetSupportedVideoSize_aligns() { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
if (DeviceEncoders.ENABLED) { |
||||
Size input = new Size(GUARANTEED_SIZE.getWidth() + 1, |
||||
GUARANTEED_SIZE.getHeight() + 1); |
||||
Size output = deviceEncoders.getSupportedVideoSize(input); |
||||
assertTrue(output.getWidth() <= input.getWidth()); |
||||
assertTrue(output.getHeight() <= input.getHeight()); |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
public void testGetSupportedVideoBitRate_disabled() { |
||||
DeviceEncoders.ENABLED = false; |
||||
DeviceEncoders deviceEncoders = create(); |
||||
int input = 1000; |
||||
int output = deviceEncoders.getSupportedVideoBitRate(input); |
||||
assertEquals(input, output); |
||||
} |
||||
|
||||
@Test |
||||
public void testGetSupportedVideoBitRate_enabled() { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
if (DeviceEncoders.ENABLED) { |
||||
// Ensure it's clamped: we can pass a negative value and check it's >= 0.
|
||||
int input = -1000; |
||||
int output = deviceEncoders.getSupportedVideoBitRate(input); |
||||
assertNotEquals(input, output); |
||||
assertTrue(output >= 0); |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
public void testGetSupportedAudioBitRate_disabled() { |
||||
DeviceEncoders.ENABLED = false; |
||||
DeviceEncoders deviceEncoders = create(); |
||||
int input = 1000; |
||||
int output = deviceEncoders.getSupportedAudioBitRate(input); |
||||
assertEquals(input, output); |
||||
} |
||||
|
||||
@Test |
||||
public void testGetSupportedAudioBitRate_enabled() { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
if (DeviceEncoders.ENABLED) { |
||||
// Ensure it's clamped: we can pass a negative value and check it's >= 0.
|
||||
int input = -1000; |
||||
int output = deviceEncoders.getSupportedAudioBitRate(input); |
||||
assertNotEquals(input, output); |
||||
assertTrue(output >= 0); |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
public void testGetSupportedFrameRate_disabled() { |
||||
DeviceEncoders.ENABLED = false; |
||||
DeviceEncoders deviceEncoders = create(); |
||||
int input = 1000; |
||||
int output = deviceEncoders.getSupportedVideoFrameRate(GUARANTEED_SIZE, input); |
||||
assertEquals(input, output); |
||||
} |
||||
|
||||
@Test |
||||
public void testGetSupportedFrameRate_enabled() { |
||||
DeviceEncoders deviceEncoders = create(); |
||||
if (DeviceEncoders.ENABLED) { |
||||
// Ensure it's clamped: we can pass a negative value and check it's >= 0.
|
||||
int input = -10; |
||||
Size inputSize = deviceEncoders.getSupportedVideoSize(GUARANTEED_SIZE); |
||||
int output = deviceEncoders.getSupportedVideoFrameRate(inputSize, input); |
||||
assertNotEquals(input, output); |
||||
assertTrue(output >= 0); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,302 @@ |
||||
package com.otaliastudios.cameraview.internal; |
||||
|
||||
import android.annotation.SuppressLint; |
||||
import android.media.MediaCodecInfo; |
||||
import android.media.MediaCodecList; |
||||
import android.os.Build; |
||||
|
||||
import androidx.annotation.NonNull; |
||||
import androidx.annotation.Nullable; |
||||
import androidx.annotation.VisibleForTesting; |
||||
|
||||
import com.otaliastudios.cameraview.CameraLogger; |
||||
import com.otaliastudios.cameraview.size.Size; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.Collections; |
||||
import java.util.Comparator; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* Checks the capabilities of device encoders and adjust parameters to ensure |
||||
* that they'll be supported by the final encoder. |
||||
* This can choose the encoder in two ways, based on the mode flag: |
||||
* |
||||
* 1. {@link #MODE_TAKE_FIRST} |
||||
* |
||||
* Chooses the encoder as the first one that matches the given mime type. |
||||
* This is what {@link android.media.MediaCodec#createEncoderByType(String)} does, |
||||
* and what {@link android.media.MediaRecorder} also does when recording. |
||||
* |
||||
* The list is ordered based on the encoder definitions in system/etc/media_codecs.xml, |
||||
* as explained here: https://source.android.com/devices/media , for example.
|
||||
* So taking the first means respecting the vendor priorities and should generally be |
||||
* a good idea. |
||||
* |
||||
* About {@link android.media.MediaRecorder}, we know it uses this option from here: |
||||
* https://stackoverflow.com/q/57479564/4288782 where all links to source code are shown.
|
||||
* - StagefrightRecorder (https://android.googlesource.com/platform/frameworks/av/+/master/media/libmediaplayerservice/StagefrightRecorder.cpp#1782)
|
||||
* - MediaCodecSource (https://android.googlesource.com/platform/frameworks/av/+/master/media/libstagefright/MediaCodecSource.cpp#515)
|
||||
* - MediaCodecList (https://android.googlesource.com/platform/frameworks/av/+/master/media/libstagefright/MediaCodecList.cpp#322)
|
||||
* |
||||
* To be fair, what {@link android.media.MediaRecorder} does is actually choose the first one |
||||
* that configures itself without errors. We currently do not offer this option here. TODO |
||||
* |
||||
* 2. {@link #MODE_PREFER_HARDWARE} |
||||
* |
||||
* This takes the list - as ordered by the vendor - and just sorts it such that hardware encoders |
||||
* are preferred over software ones. It's questionable whether this is good or not. Some vendors |
||||
* might forget to put hardware encoders first in the list, some others might put poor hardware |
||||
* encoders on the bottom of the list on purpose. |
||||
*/ |
||||
public class DeviceEncoders { |
||||
|
||||
private final static String TAG = DeviceEncoders.class.getSimpleName(); |
||||
private final static CameraLogger LOG = CameraLogger.create(TAG); |
||||
|
||||
@VisibleForTesting static boolean ENABLED = Build.VERSION.SDK_INT >= 21; |
||||
|
||||
public final static int MODE_TAKE_FIRST = 0; |
||||
public final static int MODE_PREFER_HARDWARE = 1; |
||||
|
||||
@SuppressWarnings("FieldCanBeLocal") |
||||
private final MediaCodecInfo mVideoEncoder; |
||||
@SuppressWarnings("FieldCanBeLocal") |
||||
private final MediaCodecInfo mAudioEncoder; |
||||
private final MediaCodecInfo.VideoCapabilities mVideoCapabilities; |
||||
private final MediaCodecInfo.AudioCapabilities mAudioCapabilities; |
||||
|
||||
@SuppressLint("NewApi") |
||||
public DeviceEncoders(@NonNull String videoType, @NonNull String audioType, int mode) { |
||||
// We could still get a list of MediaCodecInfo for API >= 16, but it seems that the APIs
|
||||
// for querying the availability of a specified MediaFormat were only added in 21 anyway.
|
||||
if (ENABLED) { |
||||
List<MediaCodecInfo> encoders = getDeviceEncoders(); |
||||
mVideoEncoder = findDeviceEncoder(encoders, videoType, mode); |
||||
LOG.i("Enabled. Found video encoder:", mVideoEncoder.getName()); |
||||
mAudioEncoder = findDeviceEncoder(encoders, audioType, mode); |
||||
LOG.i("Enabled. Found audio encoder:", mAudioEncoder.getName()); |
||||
mVideoCapabilities = mVideoEncoder.getCapabilitiesForType(videoType).getVideoCapabilities(); |
||||
mAudioCapabilities = mAudioEncoder.getCapabilitiesForType(audioType).getAudioCapabilities(); |
||||
} else { |
||||
mVideoEncoder = null; |
||||
mAudioEncoder = null; |
||||
mVideoCapabilities = null; |
||||
mAudioCapabilities = null; |
||||
LOG.i("Disabled."); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Collects all the device encoders, which means excluding decoders. |
||||
* @return encoders |
||||
*/ |
||||
@NonNull |
||||
@SuppressLint("NewApi") |
||||
@VisibleForTesting |
||||
List<MediaCodecInfo> getDeviceEncoders() { |
||||
ArrayList<MediaCodecInfo> results = new ArrayList<>(); |
||||
MediaCodecInfo[] array = new MediaCodecList(MediaCodecList.REGULAR_CODECS).getCodecInfos(); |
||||
for (MediaCodecInfo info : array) { |
||||
if (info.isEncoder()) results.add(info); |
||||
} |
||||
return results; |
||||
} |
||||
|
||||
/** |
||||
* Whether an encoder is a hardware encoder or not. We don't have an API to check this, |
||||
* but we can follow what libstagefright does: |
||||
* https://android.googlesource.com/platform/frameworks/av/+/master/media/libstagefright/MediaCodecList.cpp#293
|
||||
* |
||||
* @param encoder encoder |
||||
* @return true if hardware |
||||
*/ |
||||
@SuppressLint("NewApi") |
||||
@VisibleForTesting |
||||
boolean isHardwareEncoder(@NonNull String encoder) { |
||||
encoder = encoder.toLowerCase(); |
||||
boolean isSoftwareEncoder = encoder.startsWith("omx.google.") |
||||
|| encoder.startsWith("c2.android.") |
||||
|| (!encoder.startsWith("omx.") && !encoder.startsWith("c2.")); |
||||
return !isSoftwareEncoder; |
||||
} |
||||
|
||||
/** |
||||
* Finds the encoder we'll be using, depending on the given mode flag: |
||||
* - {@link #MODE_TAKE_FIRST} will just take the first of the list |
||||
* - {@link #MODE_PREFER_HARDWARE} will prefer hardware encoders |
||||
* Throws if we find no encoder for this type. |
||||
* |
||||
* @param encoders encoders |
||||
* @param mimeType mime type |
||||
* @param mode mode |
||||
* @return encoder |
||||
*/ |
||||
@SuppressLint("NewApi") |
||||
@NonNull |
||||
@VisibleForTesting |
||||
MediaCodecInfo findDeviceEncoder(@NonNull List<MediaCodecInfo> encoders, @NonNull String mimeType, int mode) { |
||||
ArrayList<MediaCodecInfo> results = new ArrayList<>(); |
||||
for (MediaCodecInfo encoder : encoders) { |
||||
String[] types = encoder.getSupportedTypes(); |
||||
for (String type : types) { |
||||
if (type.equalsIgnoreCase(mimeType)) { |
||||
results.add(encoder); |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
LOG.i("findDeviceEncoder -", "type:", mimeType, "encoders:", results.size()); |
||||
if (mode == MODE_PREFER_HARDWARE) { |
||||
Collections.sort(results, new Comparator<MediaCodecInfo>() { |
||||
@Override |
||||
public int compare(MediaCodecInfo o1, MediaCodecInfo o2) { |
||||
boolean hw1 = isHardwareEncoder(o1.getName()); |
||||
boolean hw2 = isHardwareEncoder(o2.getName()); |
||||
if (hw1 && hw2) return 0; |
||||
if (hw1) return -1; |
||||
if (hw2) return 1; |
||||
return 0; |
||||
} |
||||
}); |
||||
} |
||||
if (results.isEmpty()) { |
||||
throw new RuntimeException("No encoders for type:" + mimeType); |
||||
} |
||||
return results.get(0); |
||||
} |
||||
|
||||
/** |
||||
* Returns a video size supported by the device encoders. |
||||
* Throws if input width or height are out of the supported boundaries. |
||||
* |
||||
* @param size input size |
||||
* @return adjusted size |
||||
*/ |
||||
@SuppressLint("NewApi") |
||||
@NonNull |
||||
public Size getSupportedVideoSize(@NonNull Size size) { |
||||
if (!ENABLED) return size; |
||||
int width = size.getWidth(); |
||||
int height = size.getHeight(); |
||||
double aspect = (double) width / height; |
||||
|
||||
// If width is too large, scale down, but keep aspect ratio.
|
||||
if (mVideoCapabilities.getSupportedWidths().getUpper() < width) { |
||||
width = mVideoCapabilities.getSupportedWidths().getUpper(); |
||||
height = (int) Math.round(width / aspect); |
||||
} |
||||
|
||||
// If height is too large, scale down, but keep aspect ratio.
|
||||
if (mVideoCapabilities.getSupportedHeights().getUpper() < height) { |
||||
height = mVideoCapabilities.getSupportedHeights().getUpper(); |
||||
width = (int) Math.round(aspect * height); |
||||
} |
||||
|
||||
// Adjust the alignment.
|
||||
while (width % mVideoCapabilities.getWidthAlignment() != 0) width--; |
||||
while (height % mVideoCapabilities.getHeightAlignment() != 0) height--; |
||||
|
||||
// It's still possible that we're BELOW the lower.
|
||||
if (!mVideoCapabilities.getSupportedWidths().contains(width)) { |
||||
throw new RuntimeException("Width not supported after adjustment." + |
||||
" Desired:" + width + |
||||
" Range:" + mVideoCapabilities.getSupportedWidths()); |
||||
} |
||||
if (!mVideoCapabilities.getSupportedHeights().contains(height)) { |
||||
throw new RuntimeException("Height not supported after adjustment." + |
||||
" Desired:" + height + |
||||
" Range:" + mVideoCapabilities.getSupportedHeights()); |
||||
} |
||||
|
||||
// It's still possible that we're unsupported for other reasons.
|
||||
if (!mVideoCapabilities.isSizeSupported(width, height)) { |
||||
throw new RuntimeException("Size not supported for unknown reason." + |
||||
" Might be an aspect ratio issue." + |
||||
" Desired size:" + new Size(width, height)); |
||||
} |
||||
Size adjusted = new Size(width, height); |
||||
LOG.i("getSupportedVideoSize -", "inputSize:", size, "adjustedSize:", adjusted); |
||||
return adjusted; |
||||
} |
||||
|
||||
/** |
||||
* Returns a video bit rate supported by the device encoders. |
||||
* This means adjusting the input bit rate if needed, to match encoder constraints. |
||||
* |
||||
* @param bitRate input rate |
||||
* @return adjusted rate |
||||
*/ |
||||
@SuppressLint("NewApi") |
||||
public int getSupportedVideoBitRate(int bitRate) { |
||||
if (!ENABLED) return bitRate; |
||||
int newBitRate = mVideoCapabilities.getBitrateRange().clamp(bitRate); |
||||
LOG.i("getSupportedVideoBitRate -", "inputRate:", bitRate, "adjustedRate:", newBitRate); |
||||
return newBitRate; |
||||
} |
||||
|
||||
/** |
||||
* Returns a video frame rate supported by the device encoders. |
||||
* This means adjusting the input frame rate if needed, to match encoder constraints. |
||||
* |
||||
* @param frameRate input rate |
||||
* @return adjusted rate |
||||
*/ |
||||
@SuppressLint("NewApi") |
||||
public int getSupportedVideoFrameRate(@NonNull Size size, int frameRate) { |
||||
if (!ENABLED) return frameRate; |
||||
int newFrameRate = (int) (double) mVideoCapabilities |
||||
.getSupportedFrameRatesFor(size.getWidth(), size.getHeight()) |
||||
.clamp((double) frameRate); |
||||
LOG.i("getSupportedVideoFrameRate -", "inputRate:", frameRate, "adjustedRate:", newFrameRate); |
||||
return newFrameRate; |
||||
} |
||||
|
||||
/** |
||||
* Returns an audio bit rate supported by the device encoders. |
||||
* This means adjusting the input bit rate if needed, to match encoder constraints. |
||||
* |
||||
* @param bitRate input rate |
||||
* @return adjusted rate |
||||
*/ |
||||
@SuppressLint("NewApi") |
||||
public int getSupportedAudioBitRate(int bitRate) { |
||||
if (!ENABLED) return bitRate; |
||||
int newBitRate = mAudioCapabilities.getBitrateRange().clamp(bitRate); |
||||
LOG.i("getSupportedAudioBitRate -", "inputRate:", bitRate, "adjustedRate:", newBitRate); |
||||
return newBitRate; |
||||
} |
||||
|
||||
|
||||
// Won't do this for audio sample rate. As far as I remember, the value we're using,
|
||||
// 44.1kHz, is guaranteed to be available, and it's not configurable.
|
||||
|
||||
/** |
||||
* Returns the name of the video encoder if we were able to determine one. |
||||
* @return encoder name |
||||
*/ |
||||
@SuppressLint("NewApi") |
||||
@Nullable |
||||
public String getVideoEncoder() { |
||||
if (mVideoEncoder != null) { |
||||
return mVideoEncoder.getName(); |
||||
} else { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Returns the name of the audio encoder if we were able to determine one. |
||||
* @return encoder name |
||||
*/ |
||||
@SuppressLint("NewApi") |
||||
@Nullable |
||||
public String getAudioEncoder() { |
||||
if (mAudioEncoder != null) { |
||||
return mAudioEncoder.getName(); |
||||
} else { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue