diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java index b853519c79..eca20cbbc2 100644 --- a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/AndroidTestUtil.java @@ -21,6 +21,7 @@ import android.content.Context; import android.os.Build; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; import java.io.File; import java.io.FileWriter; import java.io.IOException; @@ -126,14 +127,16 @@ public final class AndroidTestUtil { * @param testId A unique identifier for the transformer test run. * @param testJson A {@link JSONObject} containing a summary of the test run. */ - /* package */ static void writeTestSummaryToFile( - Context context, String testId, JSONObject testJson) throws IOException, JSONException { + public static void writeTestSummaryToFile(Context context, String testId, JSONObject testJson) + throws IOException, JSONException { testJson.put("testId", testId).put("device", getDeviceDetailsAsJsonObject()); String analysisContents = testJson.toString(/* indentSpaces= */ 2); // Log contents as well as writing to file, for easier visibility on individual device testing. - Log.i(testId, analysisContents); + for (String line : Util.split(analysisContents, "\n")) { + Log.i(testId, line); + } File analysisFile = createExternalCacheFile(context, /* fileName= */ testId + "-result.txt"); try (FileWriter fileWriter = new FileWriter(analysisFile)) { diff --git a/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/analysis/EncoderCapabilityAnalysisTest.java b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/analysis/EncoderCapabilityAnalysisTest.java new file mode 100644 index 0000000000..ffe6b7b0cd --- /dev/null +++ b/library/transformer/src/androidTest/java/com/google/android/exoplayer2/transformer/mh/analysis/EncoderCapabilityAnalysisTest.java @@ -0,0 +1,197 @@ +/* + * Copyright 2022 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 com.google.android.exoplayer2.transformer.mh.analysis; + +import static android.media.MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR; +import static android.media.MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR_FD; +import static android.media.MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CQ; +import static android.media.MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR; + +import android.media.MediaCodecInfo; +import android.util.Pair; +import android.util.Range; +import android.util.Size; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.transformer.AndroidTestUtil; +import com.google.android.exoplayer2.transformer.EncoderUtil; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** An analysis test to log encoder capabilities on a device. */ +@RunWith(AndroidJUnit4.class) +public class EncoderCapabilityAnalysisTest { + + // TODO(b/228167357): Remove after bumping compileApiVersion to 33. + /** Re-definition of {@code MediaCodecInfo.CodecCapabilities.FEATURE_HdrEditing} in API33. */ + private static final String FEATURE_HdrEditing = "hdr-editing"; + /** + * Re-definition of {@code MediaCodecInfo.CodecCapabilities.FEATURE_EncodingStatistics} in API33. + */ + private static final String FEATURE_EncodingStatistics = "encoding-statistics"; + + @Test + public void logEncoderCapabilities() throws Exception { + ImmutableSet supportedVideoMimeTypes = EncoderUtil.getSupportedVideoMimeTypes(); + + // Map from MIME type to a list of maps from capability name to value. + LinkedHashMap>> mimeTypeToEncoderInfo = new LinkedHashMap<>(); + + for (String mimeType : supportedVideoMimeTypes) { + ImmutableList encoderInfos = EncoderUtil.getSupportedEncoders(mimeType); + ArrayList> encoderCapabilitiesForMimeType = new ArrayList<>(); + for (MediaCodecInfo encoderInfo : encoderInfos) { + LinkedHashMap capabilities = new LinkedHashMap<>(); + capabilities.put("encoder_name", encoderInfo.getName()); + + capabilities.put( + "is_software_encoder", !EncoderUtil.isHardwareAccelerated(encoderInfo, mimeType)); + + // Bitrate modes. + capabilities.put( + "supports_vbr", + EncoderUtil.isBitrateModeSupported(encoderInfo, mimeType, BITRATE_MODE_VBR)); + capabilities.put( + "supports_cbr", + EncoderUtil.isBitrateModeSupported(encoderInfo, mimeType, BITRATE_MODE_CBR)); + capabilities.put( + "supports_cq", + EncoderUtil.isBitrateModeSupported(encoderInfo, mimeType, BITRATE_MODE_CQ)); + capabilities.put( + "supports_cbr_fd", + EncoderUtil.isBitrateModeSupported(encoderInfo, mimeType, BITRATE_MODE_CBR_FD)); + + capabilities.put( + "supported_bitrate_range", + rangeToString(EncoderUtil.getSupportedBitrateRange(encoderInfo, mimeType))); + + // Resolution support. + Pair, Range> supportedResolutionRanges = + EncoderUtil.getSupportedResolutionRanges(encoderInfo, mimeType); + capabilities.put("supported_widths_range", rangeToString(supportedResolutionRanges.first)); + capabilities.put( + "supported_heights_range", rangeToString(supportedResolutionRanges.second)); + + checkResolutionSupport( + encoderInfo, mimeType, capabilities, /* width= */ 1280, /* height= */ 720); + checkResolutionSupport( + encoderInfo, mimeType, capabilities, /* width= */ 1920, /* height= */ 1080); + checkResolutionSupport( + encoderInfo, mimeType, capabilities, /* width= */ 2560, /* height= */ 1440); + checkResolutionSupport( + encoderInfo, mimeType, capabilities, /* width= */ 3840, /* height= */ 2160); + + checkProfileLevelSupport(encoderInfo, mimeType, capabilities); + + capabilities.put( + "supported_color_profiles", + EncoderUtil.getSupportedColorFormats(encoderInfo, mimeType)); + + capabilities.put( + "max_supported_instances", + Util.SDK_INT >= 23 ? EncoderUtil.getMaxSupportedInstances(encoderInfo, mimeType) : -1); + + capabilities.put( + "supports_qp_bounds", + Util.SDK_INT >= 31 + && EncoderUtil.isFeatureSupported( + encoderInfo, mimeType, MediaCodecInfo.CodecCapabilities.FEATURE_QpBounds)); + + capabilities.put( + "supports_hdr_editing", + Util.SDK_INT >= 33 + && EncoderUtil.isFeatureSupported(encoderInfo, mimeType, FEATURE_HdrEditing)); + + capabilities.put( + "supports_encoding_statistics", + Util.SDK_INT >= 33 + && EncoderUtil.isFeatureSupported( + encoderInfo, mimeType, FEATURE_EncodingStatistics)); + + encoderCapabilitiesForMimeType.add(capabilities); + } + mimeTypeToEncoderInfo.put(mimeType, encoderCapabilitiesForMimeType); + } + + JSONObject resultJson = new JSONObject(); + resultJson.put("encoder_capabilities", JSONObject.wrap(mimeTypeToEncoderInfo)); + AndroidTestUtil.writeTestSummaryToFile( + ApplicationProvider.getApplicationContext(), + /* testId= */ "encoderCapabilityAnalysisTest", + resultJson); + } + + private static void checkResolutionSupport( + MediaCodecInfo encoder, + String mimeType, + Map capabilities, + int width, + int height) { + Range supportedWidths = + EncoderUtil.getSupportedResolutionRanges(encoder, mimeType).first; + @Nullable Range supportedHeights = null; + if (supportedWidths.contains(width)) { + supportedHeights = EncoderUtil.getSupportedHeights(encoder, mimeType, width); + } + capabilities.put( + Util.formatInvariant("supported_heights_for_%d", width), rangeToString(supportedHeights)); + + @Nullable + Size supportedResolution = EncoderUtil.getSupportedResolution(encoder, mimeType, width, height); + if (supportedResolution == null) { + supportedResolution = new Size(/* width= */ 0, /* height= */ 0); + } + capabilities.put( + Util.formatInvariant("supports_%dx%d", width, height), + (supportedResolution.getWidth() == width && supportedResolution.getHeight() == height)); + capabilities.put( + Util.formatInvariant("fallback_%dx%d", width, height), sizeToString(supportedResolution)); + } + + private static void checkProfileLevelSupport( + MediaCodecInfo encoder, String mimeType, Map capabilities) { + LinkedHashMap profileToHighestSupportedLevel = new LinkedHashMap<>(); + ImmutableSet supportedEncodingProfiles = + EncoderUtil.findSupportedEncodingProfiles(encoder, mimeType); + for (int profile : supportedEncodingProfiles) { + profileToHighestSupportedLevel.put( + String.valueOf(profile), + String.valueOf( + EncoderUtil.findHighestSupportedEncodingLevel(encoder, mimeType, profile))); + } + capabilities.put("supported_profile_levels", profileToHighestSupportedLevel); + } + + private static String rangeToString(@Nullable Range range) { + return range == null + ? "0-0" + : Util.formatInvariant("%d-%d", range.getLower(), range.getUpper()); + } + + private static String sizeToString(@Nullable Size size) { + return size == null ? "0x0" : Util.formatInvariant("%dx%d", size.getWidth(), size.getHeight()); + } +} diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/DefaultEncoderFactory.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/DefaultEncoderFactory.java index 1e51e0d854..18dcc1c001 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/DefaultEncoderFactory.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/DefaultEncoderFactory.java @@ -267,7 +267,7 @@ public final class DefaultEncoderFactory implements Codec.EncoderFactory { MediaCodecInfo pickedEncoder = filteredEncoders.get(0); int closestSupportedBitrate = - EncoderUtil.getClosestSupportedBitrate(pickedEncoder, mimeType, requestedBitrate); + EncoderUtil.getSupportedBitrateRange(pickedEncoder, mimeType).clamp(requestedBitrate); VideoEncoderSettings.Builder supportedEncodingSettingBuilder = videoEncoderSettings.buildUpon().setBitrate(closestSupportedBitrate); @@ -320,7 +320,7 @@ public final class DefaultEncoderFactory implements Codec.EncoderFactory { encoders, /* cost= */ (encoderInfo) -> { int achievableBitrate = - EncoderUtil.getClosestSupportedBitrate(encoderInfo, mimeType, requestedBitrate); + EncoderUtil.getSupportedBitrateRange(encoderInfo, mimeType).clamp(requestedBitrate); return abs(achievableBitrate - requestedBitrate); }, /* filterName= */ "bitrate"); diff --git a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/EncoderUtil.java b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/EncoderUtil.java index ebb6842396..078b1229f4 100644 --- a/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/EncoderUtil.java +++ b/library/transformer/src/main/java/com/google/android/exoplayer2/transformer/EncoderUtil.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.transformer; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static java.lang.Math.max; import static java.lang.Math.round; @@ -23,6 +24,8 @@ import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaCodecList; import android.media.MediaFormat; +import android.util.Pair; +import android.util.Range; import android.util.Size; import androidx.annotation.DoNotInline; import androidx.annotation.Nullable; @@ -32,9 +35,12 @@ import com.google.android.exoplayer2.util.MediaFormatUtil; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.Util; import com.google.common.base.Ascii; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; import com.google.common.collect.ImmutableList; -import java.util.ArrayList; -import java.util.List; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.primitives.Ints; /** Utility methods for {@link MediaCodec} encoders. */ public final class EncoderUtil { @@ -42,26 +48,47 @@ public final class EncoderUtil { /** A value to indicate the encoding level is not set. */ public static final int LEVEL_UNSET = Format.NO_VALUE; - private static final List encoders = new ArrayList<>(); + private static final Supplier> + MIME_TYPE_TO_ENCODERS = Suppliers.memoize(EncoderUtil::populateEncoderInfos); /** * Returns a list of {@linkplain MediaCodecInfo encoders} that support the given {@code mimeType}, * or an empty list if there is none. */ public static ImmutableList getSupportedEncoders(String mimeType) { - maybePopulateEncoderInfos(); + return checkNotNull(MIME_TYPE_TO_ENCODERS.get()).get(Ascii.toLowerCase(mimeType)); + } - ImmutableList.Builder availableEncoders = new ImmutableList.Builder<>(); - for (int i = 0; i < encoders.size(); i++) { - MediaCodecInfo encoderInfo = encoders.get(i); - String[] supportedMimeTypes = encoderInfo.getSupportedTypes(); - for (String supportedMimeType : supportedMimeTypes) { - if (Ascii.equalsIgnoreCase(supportedMimeType, mimeType)) { - availableEncoders.add(encoderInfo); - } - } - } - return availableEncoders.build(); + /** Returns a list of video {@linkplain MimeTypes MIME types} that can be encoded. */ + public static ImmutableSet getSupportedVideoMimeTypes() { + return checkNotNull(MIME_TYPE_TO_ENCODERS.get()).keySet(); + } + + /** + * Returns a {@link Range} of supported heights for the given {@link MediaCodecInfo encoder}, + * {@linkplain MimeTypes MIME type} and {@code width}. + * + * @throws IllegalArgumentException When the width is not in the range of {@linkplain + * #getSupportedResolutionRanges supported widths}. + */ + public static Range getSupportedHeights( + MediaCodecInfo encoderInfo, String mimeType, int width) { + return encoderInfo + .getCapabilitiesForType(mimeType) + .getVideoCapabilities() + .getSupportedHeightsFor(width); + } + + /** + * Returns a {@link Pair} of supported width and height {@link Range ranges} for the given {@link + * MediaCodecInfo encoder} and {@linkplain MimeTypes MIME type}. + */ + public static Pair, Range> getSupportedResolutionRanges( + MediaCodecInfo encoderInfo, String mimeType) { + MediaCodecInfo.VideoCapabilities videoCapabilities = + encoderInfo.getCapabilitiesForType(mimeType).getVideoCapabilities(); + return Pair.create( + videoCapabilities.getSupportedWidths(), videoCapabilities.getSupportedHeights()); } /** @@ -130,6 +157,22 @@ public final class EncoderUtil { return videoEncoderCapabilities.isSizeSupported(width, height) ? new Size(width, height) : null; } + /** + * Returns a {@link ImmutableSet set} of supported {@linkplain MediaCodecInfo.CodecProfileLevel + * encoding profiles} for the given {@linkplain MediaCodecInfo encoder} and {@linkplain MimeTypes + * MIME type}. + */ + public static ImmutableSet findSupportedEncodingProfiles( + MediaCodecInfo encoderInfo, String mimeType) { + MediaCodecInfo.CodecProfileLevel[] profileLevels = + encoderInfo.getCapabilitiesForType(mimeType).profileLevels; + ImmutableSet.Builder supportedProfilesBuilder = new ImmutableSet.Builder<>(); + for (MediaCodecInfo.CodecProfileLevel profileLevel : profileLevels) { + supportedProfilesBuilder.add(profileLevel.profile); + } + return supportedProfilesBuilder.build(); + } + /** * Finds the highest supported encoding level given a profile. * @@ -180,17 +223,10 @@ public final class EncoderUtil { return mediaCodecName; } - /** - * Finds the {@linkplain MediaCodecInfo encoder}'s closest supported bitrate from the given - * bitrate. - */ - public static int getClosestSupportedBitrate( - MediaCodecInfo encoderInfo, String mimeType, int bitrate) { - return encoderInfo - .getCapabilitiesForType(mimeType) - .getVideoCapabilities() - .getBitrateRange() - .clamp(bitrate); + /** Returns the range of supported bitrates for the given {@linkplain MimeTypes MIME type}. */ + public static Range getSupportedBitrateRange( + MediaCodecInfo encoderInfo, String mimeType) { + return encoderInfo.getCapabilitiesForType(mimeType).getVideoCapabilities().getBitrateRange(); } /** Returns whether the bitrate mode is supported by the encoder. */ @@ -202,6 +238,17 @@ public final class EncoderUtil { .isBitrateModeSupported(bitrateMode); } + /** + * Returns a {@link ImmutableList list} of supported {@linkplain + * MediaCodecInfo.CodecCapabilities#colorFormats color formats} for the given {@linkplain + * MediaCodecInfo encoder} and {@linkplain MimeTypes MIME type}. + */ + public static ImmutableList getSupportedColorFormats( + MediaCodecInfo encoderInfo, String mimeType) { + return ImmutableList.copyOf( + Ints.asList(encoderInfo.getCapabilitiesForType(mimeType).colorFormats)); + } + /** Checks if a {@linkplain MediaCodecInfo codec} is hardware-accelerated. */ public static boolean isHardwareAccelerated(MediaCodecInfo encoderInfo, String mimeType) { // TODO(b/214964116): Merge into MediaCodecUtil. @@ -213,6 +260,18 @@ public final class EncoderUtil { return !isSoftwareOnly(encoderInfo, mimeType); } + /** Returns whether a given feature is supported. */ + public static boolean isFeatureSupported( + MediaCodecInfo encoderInfo, String mimeType, String featureName) { + return encoderInfo.getCapabilitiesForType(mimeType).isFeatureSupported(featureName); + } + + /** Returns the number of max number of the supported concurrent codec instances. */ + @RequiresApi(23) + public static int getMaxSupportedInstances(MediaCodecInfo encoderInfo, String mimeType) { + return encoderInfo.getCapabilitiesForType(mimeType).getMaxSupportedInstances(); + } + private static boolean isSoftwareOnly(MediaCodecInfo encoderInfo, String mimeType) { if (Util.SDK_INT >= 29) { return Api29.isSoftwareOnly(encoderInfo); @@ -256,18 +315,25 @@ public final class EncoderUtil { : alignment * Math.round((float) size / alignment); } - private static synchronized void maybePopulateEncoderInfos() { - if (encoders.isEmpty()) { - MediaCodecList mediaCodecList = new MediaCodecList(MediaCodecList.ALL_CODECS); - MediaCodecInfo[] allCodecInfos = mediaCodecList.getCodecInfos(); + private static ImmutableListMultimap populateEncoderInfos() { + ImmutableListMultimap.Builder encoderInfosBuilder = + new ImmutableListMultimap.Builder<>(); - for (MediaCodecInfo mediaCodecInfo : allCodecInfos) { - if (!mediaCodecInfo.isEncoder()) { - continue; + MediaCodecList mediaCodecList = new MediaCodecList(MediaCodecList.ALL_CODECS); + MediaCodecInfo[] allCodecInfos = mediaCodecList.getCodecInfos(); + + for (MediaCodecInfo mediaCodecInfo : allCodecInfos) { + if (!mediaCodecInfo.isEncoder()) { + continue; + } + String[] supportedMimeTypes = mediaCodecInfo.getSupportedTypes(); + for (String mimeType : supportedMimeTypes) { + if (MimeTypes.isVideo(mimeType)) { + encoderInfosBuilder.put(Ascii.toLowerCase(mimeType), mediaCodecInfo); } - encoders.add(mediaCodecInfo); } } + return encoderInfosBuilder.build(); } @RequiresApi(29)