MediaFormatUtil.java

/*
 * Copyright (C) 2018 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.media3.common.util;

import static androidx.media3.common.util.Util.SDK_INT;

import android.annotation.SuppressLint;
import android.media.AudioFormat;
import android.media.MediaFormat;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.ColorInfo;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import com.google.common.collect.ImmutableList;
import java.nio.ByteBuffer;
import java.util.List;

/** Helper class containing utility methods for managing {@link MediaFormat} instances. */
@UnstableApi
public final class MediaFormatUtil {

  /**
   * Custom {@link MediaFormat} key associated with a float representing the ratio between a pixel's
   * width and height.
   */
  // The constant value must not be changed, because it's also set by the framework MediaParser API.
  public static final String KEY_PIXEL_WIDTH_HEIGHT_RATIO_FLOAT =
      "exo-pixel-width-height-ratio-float";

  /**
   * Custom {@link MediaFormat} key associated with an integer representing the PCM encoding.
   *
   * <p>Equivalent to {@link MediaFormat#KEY_PCM_ENCODING}, except it allows additional values
   * defined by {@link C.PcmEncoding}, including {@link C#ENCODING_PCM_16BIT_BIG_ENDIAN}, {@link
   * C#ENCODING_PCM_24BIT}, {@link C#ENCODING_PCM_24BIT_BIG_ENDIAN}, {@link C#ENCODING_PCM_32BIT}
   * and {@link C#ENCODING_PCM_32BIT_BIG_ENDIAN}.
   */
  // The constant value must not be changed, because it's also set by the framework MediaParser API.
  public static final String KEY_PCM_ENCODING_EXTENDED = "exo-pcm-encoding-int";

  /**
   * The {@link MediaFormat} key for the maximum bitrate in bits per second.
   *
   * <p>The associated value is an integer.
   *
   * <p>The key string constant is the same as {@code MediaFormat#KEY_MAX_BITRATE}. Values for it
   * are already returned by the framework MediaExtractor; the key is a hidden field in {@code
   * MediaFormat} though, which is why it's being replicated here.
   */
  // The constant value must not be changed, because it's also set by the framework MediaParser and
  // MediaExtractor APIs.
  public static final String KEY_MAX_BIT_RATE = "max-bitrate";

  private static final int MAX_POWER_OF_TWO_INT = 1 << 30;

  /** Returns a {@link Format} representing the given {@link MediaFormat}. */
  @SuppressLint("InlinedApi") // Inlined MediaFormat keys.
  public static Format createFormatFromMediaFormat(MediaFormat mediaFormat) {
    Format.Builder formatBuilder =
        new Format.Builder()
            .setSampleMimeType(mediaFormat.getString(MediaFormat.KEY_MIME))
            .setLanguage(mediaFormat.getString(MediaFormat.KEY_LANGUAGE))
            .setPeakBitrate(
                getInteger(mediaFormat, KEY_MAX_BIT_RATE, /* defaultValue= */ Format.NO_VALUE))
            .setAverageBitrate(
                getInteger(
                    mediaFormat, MediaFormat.KEY_BIT_RATE, /* defaultValue= */ Format.NO_VALUE))
            .setCodecs(mediaFormat.getString(MediaFormat.KEY_CODECS_STRING))
            .setFrameRate(getFrameRate(mediaFormat, /* defaultValue= */ Format.NO_VALUE))
            .setWidth(
                getInteger(mediaFormat, MediaFormat.KEY_WIDTH, /* defaultValue= */ Format.NO_VALUE))
            .setHeight(
                getInteger(
                    mediaFormat, MediaFormat.KEY_HEIGHT, /* defaultValue= */ Format.NO_VALUE))
            .setPixelWidthHeightRatio(
                getPixelWidthHeightRatio(mediaFormat, /* defaultValue= */ 1.0f))
            .setMaxInputSize(
                getInteger(
                    mediaFormat,
                    MediaFormat.KEY_MAX_INPUT_SIZE,
                    /* defaultValue= */ Format.NO_VALUE))
            .setRotationDegrees(
                getInteger(mediaFormat, MediaFormat.KEY_ROTATION, /* defaultValue= */ 0))
            // TODO(b/278101856): Disallow invalid values after confirming.
            .setColorInfo(getColorInfo(mediaFormat, /* allowInvalidValues= */ true))
            .setSampleRate(
                getInteger(
                    mediaFormat, MediaFormat.KEY_SAMPLE_RATE, /* defaultValue= */ Format.NO_VALUE))
            .setChannelCount(
                getInteger(
                    mediaFormat,
                    MediaFormat.KEY_CHANNEL_COUNT,
                    /* defaultValue= */ Format.NO_VALUE))
            .setPcmEncoding(
                getInteger(
                    mediaFormat,
                    MediaFormat.KEY_PCM_ENCODING,
                    /* defaultValue= */ Format.NO_VALUE));

    ImmutableList.Builder<byte[]> csdBuffers = new ImmutableList.Builder<>();
    int csdIndex = 0;
    while (true) {
      @Nullable ByteBuffer csdByteBuffer = mediaFormat.getByteBuffer("csd-" + csdIndex);
      if (csdByteBuffer == null) {
        break;
      }
      byte[] csdBufferData = new byte[csdByteBuffer.remaining()];
      csdByteBuffer.get(csdBufferData);
      csdByteBuffer.rewind();

      csdBuffers.add(csdBufferData);
      csdIndex++;
    }

    formatBuilder.setInitializationData(csdBuffers.build());

    return formatBuilder.build();
  }

  /**
   * Returns a {@link MediaFormat} representing the given ExoPlayer {@link Format}.
   *
   * <p>May include the following custom keys:
   *
   * <ul>
   *   <li>{@link #KEY_PIXEL_WIDTH_HEIGHT_RATIO_FLOAT}.
   *   <li>{@link #KEY_PCM_ENCODING_EXTENDED}.
   * </ul>
   */
  @SuppressLint("InlinedApi") // Inlined MediaFormat keys.
  public static MediaFormat createMediaFormatFromFormat(Format format) {
    MediaFormat result = new MediaFormat();
    maybeSetInteger(result, MediaFormat.KEY_BIT_RATE, format.bitrate);
    maybeSetInteger(result, KEY_MAX_BIT_RATE, format.peakBitrate);
    maybeSetInteger(result, MediaFormat.KEY_CHANNEL_COUNT, format.channelCount);

    maybeSetColorInfo(result, format.colorInfo);

    maybeSetString(result, MediaFormat.KEY_MIME, format.sampleMimeType);
    maybeSetString(result, MediaFormat.KEY_CODECS_STRING, format.codecs);
    maybeSetFloat(result, MediaFormat.KEY_FRAME_RATE, format.frameRate);
    maybeSetInteger(result, MediaFormat.KEY_WIDTH, format.width);
    maybeSetInteger(result, MediaFormat.KEY_HEIGHT, format.height);

    setCsdBuffers(result, format.initializationData);
    maybeSetPcmEncoding(result, format.pcmEncoding);
    maybeSetString(result, MediaFormat.KEY_LANGUAGE, format.language);
    maybeSetInteger(result, MediaFormat.KEY_MAX_INPUT_SIZE, format.maxInputSize);
    maybeSetInteger(result, MediaFormat.KEY_SAMPLE_RATE, format.sampleRate);
    maybeSetInteger(result, MediaFormat.KEY_CAPTION_SERVICE_NUMBER, format.accessibilityChannel);
    result.setInteger(MediaFormat.KEY_ROTATION, format.rotationDegrees);

    int selectionFlags = format.selectionFlags;
    setBooleanAsInt(
        result, MediaFormat.KEY_IS_AUTOSELECT, selectionFlags & C.SELECTION_FLAG_AUTOSELECT);
    setBooleanAsInt(result, MediaFormat.KEY_IS_DEFAULT, selectionFlags & C.SELECTION_FLAG_DEFAULT);
    setBooleanAsInt(
        result, MediaFormat.KEY_IS_FORCED_SUBTITLE, selectionFlags & C.SELECTION_FLAG_FORCED);

    result.setInteger(MediaFormat.KEY_ENCODER_DELAY, format.encoderDelay);
    result.setInteger(MediaFormat.KEY_ENCODER_PADDING, format.encoderPadding);

    maybeSetPixelAspectRatio(result, format.pixelWidthHeightRatio);
    return result;
  }

  /**
   * Sets a {@link MediaFormat} {@link String} value. Does nothing if {@code value} is null.
   *
   * @param format The {@link MediaFormat} being configured.
   * @param key The key to set.
   * @param value The value to set.
   */
  public static void maybeSetString(MediaFormat format, String key, @Nullable String value) {
    if (value != null) {
      format.setString(key, value);
    }
  }

  /**
   * Sets a {@link MediaFormat}'s codec specific data buffers.
   *
   * @param format The {@link MediaFormat} being configured.
   * @param csdBuffers The csd buffers to set.
   */
  public static void setCsdBuffers(MediaFormat format, List<byte[]> csdBuffers) {
    for (int i = 0; i < csdBuffers.size(); i++) {
      format.setByteBuffer("csd-" + i, ByteBuffer.wrap(csdBuffers.get(i)));
    }
  }

  /**
   * Sets a {@link MediaFormat} integer value. Does nothing if {@code value} is {@link
   * Format#NO_VALUE}.
   *
   * @param format The {@link MediaFormat} being configured.
   * @param key The key to set.
   * @param value The value to set.
   */
  public static void maybeSetInteger(MediaFormat format, String key, int value) {
    if (value != Format.NO_VALUE) {
      format.setInteger(key, value);
    }
  }

  /**
   * Sets a {@link MediaFormat} float value. Does nothing if {@code value} is {@link
   * Format#NO_VALUE}.
   *
   * @param format The {@link MediaFormat} being configured.
   * @param key The key to set.
   * @param value The value to set.
   */
  public static void maybeSetFloat(MediaFormat format, String key, float value) {
    if (value != Format.NO_VALUE) {
      format.setFloat(key, value);
    }
  }

  /**
   * Sets a {@link MediaFormat} {@link ByteBuffer} value. Does nothing if {@code value} is null.
   *
   * @param format The {@link MediaFormat} being configured.
   * @param key The key to set.
   * @param value The byte array that will be wrapped to obtain the value.
   */
  public static void maybeSetByteBuffer(MediaFormat format, String key, @Nullable byte[] value) {
    if (value != null) {
      format.setByteBuffer(key, ByteBuffer.wrap(value));
    }
  }

  /**
   * Sets a {@link MediaFormat}'s color information. Does nothing if {@code colorInfo} is null.
   *
   * @param format The {@link MediaFormat} being configured.
   * @param colorInfo The color info to set.
   */
  @SuppressWarnings("InlinedApi")
  public static void maybeSetColorInfo(MediaFormat format, @Nullable ColorInfo colorInfo) {
    if (colorInfo != null) {
      maybeSetInteger(format, MediaFormat.KEY_COLOR_TRANSFER, colorInfo.colorTransfer);
      maybeSetInteger(format, MediaFormat.KEY_COLOR_STANDARD, colorInfo.colorSpace);
      maybeSetInteger(format, MediaFormat.KEY_COLOR_RANGE, colorInfo.colorRange);
      maybeSetByteBuffer(format, MediaFormat.KEY_HDR_STATIC_INFO, colorInfo.hdrStaticInfo);
    }
  }

  /**
   * Creates and returns a {@code ColorInfo}, if a valid instance is described in the {@link
   * MediaFormat}.
   *
   * <p>Under API 24, {@code null} will always be returned, because {@link MediaFormat} color keys
   * like {@link MediaFormat#KEY_COLOR_STANDARD} were only added in API 24.
   */
  @Nullable
  public static ColorInfo getColorInfo(MediaFormat mediaFormat) {
    return getColorInfo(mediaFormat, /* allowInvalidValues= */ false);
  }

  // Internal methods.

  @Nullable
  private static ColorInfo getColorInfo(MediaFormat mediaFormat, boolean allowInvalidValues) {
    if (SDK_INT < 24) {
      // MediaFormat KEY_COLOR_TRANSFER and other KEY_COLOR values available from API 24.
      return null;
    }
    int colorSpace =
        getInteger(
            mediaFormat, MediaFormat.KEY_COLOR_STANDARD, /* defaultValue= */ Format.NO_VALUE);
    int colorRange =
        getInteger(mediaFormat, MediaFormat.KEY_COLOR_RANGE, /* defaultValue= */ Format.NO_VALUE);
    int colorTransfer =
        getInteger(
            mediaFormat, MediaFormat.KEY_COLOR_TRANSFER, /* defaultValue= */ Format.NO_VALUE);
    @Nullable
    ByteBuffer hdrStaticInfoByteBuffer = mediaFormat.getByteBuffer(MediaFormat.KEY_HDR_STATIC_INFO);
    @Nullable
    byte[] hdrStaticInfo =
        hdrStaticInfoByteBuffer != null ? getArray(hdrStaticInfoByteBuffer) : null;

    if (!allowInvalidValues) {
      // Some devices may produce invalid values from MediaFormat#getInteger.
      // See b/239435670 for more information.
      if (!isValidColorSpace(colorSpace)) {
        colorSpace = Format.NO_VALUE;
      }
      if (!isValidColorRange(colorRange)) {
        colorRange = Format.NO_VALUE;
      }
      if (!isValidColorTransfer(colorTransfer)) {
        colorTransfer = Format.NO_VALUE;
      }
    }

    if (colorSpace != Format.NO_VALUE
        || colorRange != Format.NO_VALUE
        || colorTransfer != Format.NO_VALUE
        || hdrStaticInfo != null) {
      return new ColorInfo.Builder()
          .setColorSpace(colorSpace)
          .setColorRange(colorRange)
          .setColorTransfer(colorTransfer)
          .setHdrStaticInfo(hdrStaticInfo)
          .build();
    }
    return null;
  }

  /** Supports {@link MediaFormat#getInteger(String, int)} for {@code API < 29}. */
  public static int getInteger(MediaFormat mediaFormat, String name, int defaultValue) {
    return mediaFormat.containsKey(name) ? mediaFormat.getInteger(name) : defaultValue;
  }

  /** Supports {@link MediaFormat#getFloat(String, float)} for {@code API < 29}. */
  public static float getFloat(MediaFormat mediaFormat, String name, float defaultValue) {
    return mediaFormat.containsKey(name) ? mediaFormat.getFloat(name) : defaultValue;
  }

  /**
   * Returns the frame rate from a {@link MediaFormat}.
   *
   * <p>The {@link MediaFormat#KEY_FRAME_RATE} can have both integer and float value so it returns
   * which ever value is set.
   */
  private static float getFrameRate(MediaFormat mediaFormat, float defaultValue) {
    float frameRate = defaultValue;
    if (mediaFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
      try {
        frameRate = mediaFormat.getFloat(MediaFormat.KEY_FRAME_RATE);
      } catch (ClassCastException ex) {
        frameRate = mediaFormat.getInteger(MediaFormat.KEY_FRAME_RATE);
      }
    }
    return frameRate;
  }

  /** Returns the ratio between a pixel's width and height for a {@link MediaFormat}. */
  // Inlined MediaFormat.KEY_PIXEL_ASPECT_RATIO_WIDTH and MediaFormat.KEY_PIXEL_ASPECT_RATIO_HEIGHT.
  @SuppressLint("InlinedApi")
  private static float getPixelWidthHeightRatio(MediaFormat mediaFormat, float defaultValue) {
    if (mediaFormat.containsKey(MediaFormat.KEY_PIXEL_ASPECT_RATIO_WIDTH)
        && mediaFormat.containsKey(MediaFormat.KEY_PIXEL_ASPECT_RATIO_HEIGHT)) {
      return (float) mediaFormat.getInteger(MediaFormat.KEY_PIXEL_ASPECT_RATIO_WIDTH)
          / (float) mediaFormat.getInteger(MediaFormat.KEY_PIXEL_ASPECT_RATIO_HEIGHT);
    }

    return defaultValue;
  }

  public static byte[] getArray(ByteBuffer byteBuffer) {
    byte[] array = new byte[byteBuffer.remaining()];
    byteBuffer.get(array);
    return array;
  }

  /** Returns whether a {@link MediaFormat} is a video format. */
  public static boolean isVideoFormat(MediaFormat mediaFormat) {
    return MimeTypes.isVideo(mediaFormat.getString(MediaFormat.KEY_MIME));
  }

  /** Returns whether a {@link MediaFormat} is an audio format. */
  public static boolean isAudioFormat(MediaFormat mediaFormat) {
    return MimeTypes.isAudio(mediaFormat.getString(MediaFormat.KEY_MIME));
  }

  /** Returns the time lapse capture FPS from the given {@link MediaFormat} if it was set. */
  @Nullable
  public static Integer getTimeLapseFrameRate(MediaFormat format) {
    if (format.containsKey("time-lapse-enable")
        && format.getInteger("time-lapse-enable") > 0
        && format.containsKey("time-lapse-fps")) {
      return format.getInteger("time-lapse-fps");
    } else {
      return null;
    }
  }

  // Internal methods.

  private static void setBooleanAsInt(MediaFormat format, String key, int value) {
    format.setInteger(key, value != 0 ? 1 : 0);
  }

  // Inlined MediaFormat.KEY_PIXEL_ASPECT_RATIO_WIDTH and MediaFormat.KEY_PIXEL_ASPECT_RATIO_HEIGHT.
  @SuppressLint("InlinedApi")
  private static void maybeSetPixelAspectRatio(
      MediaFormat mediaFormat, float pixelWidthHeightRatio) {
    mediaFormat.setFloat(KEY_PIXEL_WIDTH_HEIGHT_RATIO_FLOAT, pixelWidthHeightRatio);
    int pixelAspectRatioWidth = 1;
    int pixelAspectRatioHeight = 1;
    // ExoPlayer extractors output the pixel aspect ratio as a float. Do our best to recreate the
    // pixel aspect ratio width and height by using a large power of two factor.
    if (pixelWidthHeightRatio < 1.0f) {
      pixelAspectRatioHeight = MAX_POWER_OF_TWO_INT;
      pixelAspectRatioWidth = (int) (pixelWidthHeightRatio * pixelAspectRatioHeight);
    } else if (pixelWidthHeightRatio > 1.0f) {
      pixelAspectRatioWidth = MAX_POWER_OF_TWO_INT;
      pixelAspectRatioHeight = (int) (pixelAspectRatioWidth / pixelWidthHeightRatio);
    }
    mediaFormat.setInteger(MediaFormat.KEY_PIXEL_ASPECT_RATIO_WIDTH, pixelAspectRatioWidth);
    mediaFormat.setInteger(MediaFormat.KEY_PIXEL_ASPECT_RATIO_HEIGHT, pixelAspectRatioHeight);
  }

  @SuppressLint("InlinedApi") // Inlined KEY_PCM_ENCODING.
  private static void maybeSetPcmEncoding(
      MediaFormat mediaFormat, @C.PcmEncoding int exoPcmEncoding) {
    if (exoPcmEncoding == Format.NO_VALUE) {
      return;
    }
    int mediaFormatPcmEncoding;
    maybeSetInteger(mediaFormat, KEY_PCM_ENCODING_EXTENDED, exoPcmEncoding);
    switch (exoPcmEncoding) {
      case C.ENCODING_PCM_8BIT:
        mediaFormatPcmEncoding = AudioFormat.ENCODING_PCM_8BIT;
        break;
      case C.ENCODING_PCM_16BIT:
        mediaFormatPcmEncoding = AudioFormat.ENCODING_PCM_16BIT;
        break;
      case C.ENCODING_PCM_FLOAT:
        mediaFormatPcmEncoding = AudioFormat.ENCODING_PCM_FLOAT;
        break;
      case C.ENCODING_PCM_24BIT:
        mediaFormatPcmEncoding = AudioFormat.ENCODING_PCM_24BIT_PACKED;
        break;
      case C.ENCODING_PCM_32BIT:
        mediaFormatPcmEncoding = AudioFormat.ENCODING_PCM_32BIT;
        break;
      case C.ENCODING_INVALID:
        mediaFormatPcmEncoding = AudioFormat.ENCODING_INVALID;
        break;
      case Format.NO_VALUE:
      case C.ENCODING_PCM_16BIT_BIG_ENDIAN:
      case C.ENCODING_PCM_24BIT_BIG_ENDIAN:
      case C.ENCODING_PCM_32BIT_BIG_ENDIAN:
      default:
        // No matching value. Do nothing.
        return;
    }
    mediaFormat.setInteger(MediaFormat.KEY_PCM_ENCODING, mediaFormatPcmEncoding);
  }

  /** Whether this is a valid {@link C.ColorSpace} instance. */
  private static boolean isValidColorSpace(int colorSpace) {
    // LINT.IfChange(color_space)
    return colorSpace == C.COLOR_SPACE_BT601
        || colorSpace == C.COLOR_SPACE_BT709
        || colorSpace == C.COLOR_SPACE_BT2020
        || colorSpace == Format.NO_VALUE;
  }

  /** Whether this is a valid {@link C.ColorRange} instance. */
  private static boolean isValidColorRange(int colorRange) {
    // LINT.IfChange(color_range)
    return colorRange == C.COLOR_RANGE_LIMITED
        || colorRange == C.COLOR_RANGE_FULL
        || colorRange == Format.NO_VALUE;
  }

  /** Whether this is a valid {@link C.ColorTransfer} instance. */
  private static boolean isValidColorTransfer(int colorTransfer) {
    // LINT.IfChange(color_transfer)
    // C.COLOR_TRANSFER_GAMMA_2_2 & C.COLOR_TRANSFER_SRGB aren't valid because MediaCodec, and
    // hence MediaFormat, do not support them.
    return colorTransfer == C.COLOR_TRANSFER_LINEAR
        || colorTransfer == C.COLOR_TRANSFER_SDR
        || colorTransfer == C.COLOR_TRANSFER_ST2084
        || colorTransfer == C.COLOR_TRANSFER_HLG
        || colorTransfer == Format.NO_VALUE;
  }

  private MediaFormatUtil() {}
}