Presentation.java

/*
 * 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 androidx.media3.transformer;

import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.SOURCE;

import android.graphics.Matrix;
import android.util.Size;
import androidx.annotation.IntDef;
import androidx.media3.common.C;
import androidx.media3.common.util.GlUtil;
import androidx.media3.common.util.UnstableApi;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;

/**
 * Controls how a frame is presented with options to set the output resolution, crop the input, and
 * choose how to map the input pixels onto the output frame geometry (for example, by stretching the
 * input frame to match the specified output frame, or fitting the input frame using letterboxing).
 *
 * <p>Cropping or aspect ratio is applied before setting resolution.
 *
 * <p>The background color of the output frame will be black.
 */
@UnstableApi
public final class Presentation implements MatrixTransformation {

  /**
   * Strategies controlling the layout of input pixels in the output frame.
   *
   * <p>One of {@link #LAYOUT_SCALE_TO_FIT}, {@link #LAYOUT_SCALE_TO_FIT_WITH_CROP}, or {@link
   * #LAYOUT_STRETCH_TO_FIT}.
   *
   * <p>May scale either width or height, leaving the other output dimension equal to its input,
   * unless {@link Builder#setResolution(int)} rescales width and height.
   */
  @Documented
  @Retention(SOURCE)
  @Target(TYPE_USE)
  @IntDef({LAYOUT_SCALE_TO_FIT, LAYOUT_SCALE_TO_FIT_WITH_CROP, LAYOUT_STRETCH_TO_FIT})
  public @interface Layout {}
  /**
   * Empty pixels added above and below the input frame (for letterboxing), or to the left and right
   * of the input frame (for pillarboxing), until the desired aspect ratio is achieved. All input
   * frame pixels will be within the output frame.
   *
   * <p>When applying:
   *
   * <ul>
   *   <li>letterboxing, the output width will default to the input width, and the output height
   *       will be scaled appropriately.
   *   <li>pillarboxing, the output height will default to the input height, and the output width
   *       will be scaled appropriately.
   * </ul>
   */
  public static final int LAYOUT_SCALE_TO_FIT = 0;
  /**
   * Pixels cropped from the input frame, until the desired aspect ratio is achieved. Pixels may be
   * cropped either from the bottom and top, or from the left and right sides, of the input frame.
   *
   * <p>When cropping from the:
   *
   * <ul>
   *   <li>bottom and top, the output width will default to the input width, and the output height
   *       will be scaled appropriately.
   *   <li>left and right, the output height will default to the input height, and the output width
   *       will be scaled appropriately.
   * </ul>
   */
  public static final int LAYOUT_SCALE_TO_FIT_WITH_CROP = 1;
  /**
   * Frame stretched larger on the x or y axes to fit the desired aspect ratio.
   *
   * <p>When stretching to a:
   *
   * <ul>
   *   <li>taller aspect ratio, the output width will default to the input width, and the output
   *       height will be scaled appropriately.
   *   <li>narrower aspect ratio, the output height will default to the input height, and the output
   *       width will be scaled appropriately.
   * </ul>
   */
  public static final int LAYOUT_STRETCH_TO_FIT = 2;

  /** A builder for {@link Presentation} instances. */
  public static final class Builder {

    // Optional fields.
    private int heightPixels;
    private float cropLeft;
    private float cropRight;
    private float cropBottom;
    private float cropTop;
    private float aspectRatio;
    private @Layout int layout;

    /** Creates a builder with default values. */
    public Builder() {
      heightPixels = C.LENGTH_UNSET;
      cropLeft = -1f;
      cropRight = 1f;
      cropBottom = -1f;
      cropTop = 1f;
      aspectRatio = C.LENGTH_UNSET;
    }

    /**
     * Sets the output resolution using the output height.
     *
     * <p>The default value, {@link C#LENGTH_UNSET}, corresponds to using the same height as the
     * input. Output width of the displayed frame will scale to preserve the frame's aspect ratio
     * after other transformations.
     *
     * <p>For example, a 1920x1440 frame can be scaled to 640x480 by calling {@code
     * setResolution(480)}.
     *
     * @param height The output height of the displayed frame, in pixels.
     * @return This builder.
     */
    public Builder setResolution(int height) {
      this.heightPixels = height;
      return this;
    }

    /**
     * Crops a smaller (or larger frame), per normalized device coordinates (NDC), where the input
     * frame corresponds to the square ranging from -1 to 1 on the x and y axes.
     *
     * <p>{@code left} and {@code bottom} default to -1, and {@code right} and {@code top} default
     * to 1, which corresponds to not applying any crop. To crop to a smaller subset of the input
     * frame, use values between -1 and 1. To crop to a larger frame, use values below -1 and above
     * 1.
     *
     * <p>Width and height values set may be rescaled by {@link #setResolution(int)}, which is
     * applied after cropping changes.
     *
     * <p>Only one of {@code setCrop} or {@link #setAspectRatio(float, int)} can be called for one
     * {@link Presentation}.
     *
     * @param left The left edge of the output frame, in NDC. Must be less than {@code right}.
     * @param right The right edge of the output frame, in NDC. Must be greater than {@code left}.
     * @param bottom The bottom edge of the output frame, in NDC. Must be less than {@code top}.
     * @param top The top edge of the output frame, in NDC. Must be greater than {@code bottom}.
     * @return This builder.
     */
    public Builder setCrop(float left, float right, float bottom, float top) {
      checkArgument(
          right > left, "right value " + right + " should be greater than left value " + left);
      checkArgument(
          top > bottom, "top value " + top + " should be greater than bottom value " + bottom);
      checkState(
          aspectRatio == C.LENGTH_UNSET,
          "setAspectRatio and setCrop cannot be called in the same instance");
      cropLeft = left;
      cropRight = right;
      cropBottom = bottom;
      cropTop = top;

      return this;
    }

    /**
     * Sets the aspect ratio (width/height ratio) for the output frame.
     *
     * <p>Resizes a frame's width or height to conform to an {@code aspectRatio}, given a {@link
     * Layout}. {@code aspectRatio} defaults to {@link C#LENGTH_UNSET}, which corresponds to the
     * same aspect ratio as the input frame. {@code layout} defaults to {@link #LAYOUT_SCALE_TO_FIT}
     *
     * <p>Width and height values set may be rescaled by {@link #setResolution(int)}, which is
     * applied after aspect ratio changes.
     *
     * <p>Only one of {@link #setCrop(float, float, float, float)} or {@code setAspectRatio} can be
     * called for one {@link Presentation}.
     *
     * @param aspectRatio The aspect ratio (width/height ratio) of the output frame. Must be
     *     positive.
     * @return This builder.
     */
    public Builder setAspectRatio(float aspectRatio, @Layout int layout) {
      checkArgument(aspectRatio > 0, "aspect ratio " + aspectRatio + " must be positive");
      checkArgument(
          layout == LAYOUT_SCALE_TO_FIT
              || layout == LAYOUT_SCALE_TO_FIT_WITH_CROP
              || layout == LAYOUT_STRETCH_TO_FIT,
          "invalid layout " + layout);
      checkState(
          cropLeft == -1f && cropRight == 1f && cropBottom == -1f && cropTop == 1f,
          "setAspectRatio and setCrop cannot be called in the same instance");
      this.aspectRatio = aspectRatio;
      this.layout = layout;
      return this;
    }

    public Presentation build() {
      return new Presentation(
          heightPixels, cropLeft, cropRight, cropBottom, cropTop, aspectRatio, layout);
    }
  }

  static {
    GlUtil.glAssertionsEnabled = true;
  }

  private final int requestedHeightPixels;
  private final float cropLeft;
  private final float cropRight;
  private final float cropBottom;
  private final float cropTop;
  private final float requestedAspectRatio;
  private final @Layout int layout;

  private float outputWidth;
  private float outputHeight;
  private @MonotonicNonNull Matrix transformationMatrix;

  /** Creates a new instance. */
  private Presentation(
      int requestedHeightPixels,
      float cropLeft,
      float cropRight,
      float cropBottom,
      float cropTop,
      float requestedAspectRatio,
      @Layout int layout) {
    this.requestedHeightPixels = requestedHeightPixels;
    this.cropLeft = cropLeft;
    this.cropRight = cropRight;
    this.cropBottom = cropBottom;
    this.cropTop = cropTop;
    this.requestedAspectRatio = requestedAspectRatio;
    this.layout = layout;

    outputWidth = C.LENGTH_UNSET;
    outputHeight = C.LENGTH_UNSET;
    transformationMatrix = new Matrix();
  }

  @Override
  public Size configure(int inputWidth, int inputHeight) {
    checkArgument(inputWidth > 0, "inputWidth must be positive");
    checkArgument(inputHeight > 0, "inputHeight must be positive");

    transformationMatrix = new Matrix();
    outputWidth = inputWidth;
    outputHeight = inputHeight;

    if (cropLeft != -1f || cropRight != 1f || cropBottom != -1f || cropTop != 1f) {
      checkState(
          requestedAspectRatio == C.LENGTH_UNSET,
          "aspect ratio and crop cannot both be set in the same instance");
      applyCrop();
    } else if (requestedAspectRatio != C.LENGTH_UNSET) {
      applyAspectRatio();
    }

    // Scale width and height to desired requestedHeightPixels, preserving aspect ratio.
    if (requestedHeightPixels != C.LENGTH_UNSET && requestedHeightPixels != outputHeight) {
      outputWidth = requestedHeightPixels * outputWidth / outputHeight;
      outputHeight = requestedHeightPixels;
    }
    return new Size(Math.round(outputWidth), Math.round(outputHeight));
  }

  @Override
  public Matrix getMatrix(long presentationTimeUs) {
    return checkStateNotNull(transformationMatrix, "configure must be called first");
  }

  @RequiresNonNull("transformationMatrix")
  private void applyCrop() {
    float scaleX = (cropRight - cropLeft) / GlUtil.LENGTH_NDC;
    float scaleY = (cropTop - cropBottom) / GlUtil.LENGTH_NDC;
    float centerX = (cropLeft + cropRight) / 2;
    float centerY = (cropBottom + cropTop) / 2;

    transformationMatrix.postTranslate(-centerX, -centerY);
    transformationMatrix.postScale(1f / scaleX, 1f / scaleY);

    outputWidth = outputWidth * scaleX;
    outputHeight = outputHeight * scaleY;
  }

  @RequiresNonNull("transformationMatrix")
  private void applyAspectRatio() {
    float inputAspectRatio = outputWidth / outputHeight;
    if (layout == LAYOUT_SCALE_TO_FIT) {
      if (requestedAspectRatio > inputAspectRatio) {
        transformationMatrix.setScale(inputAspectRatio / requestedAspectRatio, 1f);
        outputWidth = outputHeight * requestedAspectRatio;
      } else {
        transformationMatrix.setScale(1f, requestedAspectRatio / inputAspectRatio);
        outputHeight = outputWidth / requestedAspectRatio;
      }
    } else if (layout == LAYOUT_SCALE_TO_FIT_WITH_CROP) {
      if (requestedAspectRatio > inputAspectRatio) {
        transformationMatrix.setScale(1f, requestedAspectRatio / inputAspectRatio);
        outputHeight = outputWidth / requestedAspectRatio;
      } else {
        transformationMatrix.setScale(inputAspectRatio / requestedAspectRatio, 1f);
        outputWidth = outputHeight * requestedAspectRatio;
      }
    } else if (layout == LAYOUT_STRETCH_TO_FIT) {
      if (requestedAspectRatio > inputAspectRatio) {
        outputWidth = outputHeight * requestedAspectRatio;
      } else {
        outputHeight = outputWidth / requestedAspectRatio;
      }
    }
  }
}