NextGenDynamicMediaRendition.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2024 wcm.io
 * %%
 * 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.
 * #L%
 */
package io.wcm.handler.mediasource.ngdm;

import java.util.Date;
import java.util.Objects;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.ValueMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.wcm.handler.media.Dimension;
import io.wcm.handler.media.MediaArgs;
import io.wcm.handler.media.MediaFileType;
import io.wcm.handler.media.Rendition;
import io.wcm.handler.media.UriTemplate;
import io.wcm.handler.media.UriTemplateType;
import io.wcm.handler.media.format.MediaFormat;
import io.wcm.handler.media.format.Ratio;
import io.wcm.handler.media.impl.ImageQualityPercentage;
import io.wcm.handler.mediasource.ngdm.impl.MediaArgsDimension;
import io.wcm.handler.mediasource.ngdm.impl.NextGenDynamicMediaBinaryUrlBuilder;
import io.wcm.handler.mediasource.ngdm.impl.NextGenDynamicMediaContext;
import io.wcm.handler.mediasource.ngdm.impl.NextGenDynamicMediaImageDeliveryParams;
import io.wcm.handler.mediasource.ngdm.impl.NextGenDynamicMediaImageUrlBuilder;
import io.wcm.handler.mediasource.ngdm.impl.NextGenDynamicMediaReference;
import io.wcm.handler.mediasource.ngdm.impl.metadata.NextGenDynamicMediaMetadata;

/**
 * {@link Rendition} implementation for Next Gen. Dynamic Media remote assets.
 */
final class NextGenDynamicMediaRendition implements Rendition {

  private final NextGenDynamicMediaContext context;
  private final NextGenDynamicMediaMetadata metadata;
  private final Dimension originalDimension;
  private final NextGenDynamicMediaReference reference;
  private final MediaArgs mediaArgs;
  private final String url;
  private MediaFormat resolvedMediaFormat;
  private long requestedWidth;
  private long requestedHeight;
  private long width;
  private long height;
  private String fileExtension;

  private static final Logger log = LoggerFactory.getLogger(NextGenDynamicMediaRendition.class);

  NextGenDynamicMediaRendition(@NotNull NextGenDynamicMediaContext context, @NotNull MediaArgs mediaArgs) {
    this.context = context;
    this.metadata = context.getMetadata();
    if (this.metadata != null) {
      this.originalDimension = metadata.getDimension();
    }
    else {
      this.originalDimension = null;
    }
    this.reference = context.getReference();
    this.mediaArgs = mediaArgs;
    this.requestedWidth = mediaArgs.getFixedWidth();
    this.requestedHeight = mediaArgs.getFixedHeight();

    // set first media format as resolved format - because only the first is supported
    MediaFormat firstMediaFormat = MediaArgsDimension.getFirstMediaFormat(mediaArgs);
    if (firstMediaFormat != null) {
      this.resolvedMediaFormat = firstMediaFormat;
      if (this.requestedWidth == 0) {
        this.requestedWidth = firstMediaFormat.getEffectiveMinWidth();
        this.requestedHeight = firstMediaFormat.getEffectiveMinHeight();
      }
    }

    this.fileExtension = mediaArgs.getEnforceOutputFileExtension();
    if (StringUtils.isEmpty(this.fileExtension)) {
      this.fileExtension = FilenameUtils.getExtension(reference.getFileName());
    }

    if (isVectorImage() || !isImage() || mediaArgs.isDownload()) {
      // deliver as binary
      this.url = buildBinaryUrl();
    }
    else {
      // calculate width/height for rendition metadata
      calculateWidthHeight();
      if (isRequestedDimensionLargerThanOriginal()) {
        // image upscaling is not supported
        this.url = null;
      }
      else {
        // deliver scaled image rendition
        this.url = buildImageRenditionUrl();
        this.fileExtension = new NextGenDynamicMediaImageUrlBuilder(context).getFileExtension();
      }
    }
  }

  /**
   * Recalculates width and/or height based on requested media format, ratio and original dimensions.
   */
  private void calculateWidthHeight() {
    double requestedRatio = MediaArgsDimension.getRequestedRatio(mediaArgs);

    // use given width/height if fixed dimension is requested
    if (requestedWidth > 0 && requestedHeight > 0) {
      this.width = requestedWidth;
      this.height = requestedHeight;
    }

    // set original sizes if not width/height is requested
    else if (this.requestedWidth == 0 && this.requestedHeight == 0 && this.originalDimension != null) {
      this.width = this.originalDimension.getWidth();
      this.height = this.originalDimension.getHeight();
    }

    // calculate height if only width is requested
    else if (this.requestedWidth > 0 && this.requestedHeight == 0) {
      this.width = requestedWidth;
      if (requestedRatio > 0) {
        this.height = Math.round(this.requestedWidth / requestedRatio);
        this.requestedHeight = this.height;
      }
      else if (originalDimension != null) {
        this.height = Math.round(this.requestedWidth / Ratio.get(originalDimension));
      }
    }

    // calculate width if only height is requested
    else if (this.requestedHeight > 0 && this.requestedWidth == 0) {
      this.height = requestedHeight;
      if (requestedRatio > 0) {
        this.width = Math.round(this.requestedHeight * requestedRatio);
        this.requestedWidth = this.width;
      }
      else if (originalDimension != null) {
        this.width = Math.round(this.requestedHeight * Ratio.get(originalDimension));
      }
    }
  }

  /**
   * Build image rendition URL which is dynamically scaled and/or cropped.
   */
  private String buildImageRenditionUrl() {
    NextGenDynamicMediaImageDeliveryParams params = new NextGenDynamicMediaImageDeliveryParams()
        .rotation(context.getMedia().getRotation())
        .quality(ImageQualityPercentage.getAsInteger(mediaArgs, context.getMediaHandlerConfig()));
    if (this.requestedWidth > 0) {
      params.width(this.requestedWidth);
    }
    if (this.requestedHeight > 0) {
      params.height(this.requestedHeight);
    }
    Dimension ratioDimension = MediaArgsDimension.getRequestedRatioAsWidthHeight(mediaArgs);
    if (ratioDimension != null) {
      params.ratio(ratioDimension);
    }

    return new NextGenDynamicMediaImageUrlBuilder(context).build(params);
  }

  /**
   * Checks if the original dimension is available in remote asset metadata, and if that dimension
   * is smaller than the requested width/height. Upscaling should be avoided.
   * @return true if requested dimension is larger than original dimension
   */
  private boolean isRequestedDimensionLargerThanOriginal() {
    if (originalDimension != null
        && (this.requestedWidth > originalDimension.getWidth() || this.requestedHeight > originalDimension.getHeight())) {
      if (log.isTraceEnabled()) {
        log.trace("Requested dimension {} is larger than original image dimension {} of {}",
            new Dimension(this.requestedWidth, this.requestedHeight), originalDimension, context.getReference());
      }
      return true;
    }
    return false;
  }

  /**
   * Build URL which points directly to the binary file.
   */
  private String buildBinaryUrl() {
    return new NextGenDynamicMediaBinaryUrlBuilder(context).build(mediaArgs.isContentDispositionAttachment());
  }

  @Override
  public @Nullable String getUrl() {
    return url;
  }

  @Override
  public @Nullable String getPath() {
    // not supported
    return null;
  }

  @Override
  public @Nullable String getFileName() {
    return reference.getFileName();
  }

  @Override
  public @Nullable String getFileExtension() {
    return this.fileExtension;
  }

  @Override
  public long getFileSize() {
    // file size is unknown
    return -1;
  }

  @Override
  public @Nullable String getMimeType() {
    if (this.metadata != null) {
      return this.metadata.getMimeType();
    }
    else {
      return context.getMimeTypeService().getMimeType(getFileExtension());
    }
  }

  @Override
  public @Nullable MediaFormat getMediaFormat() {
    return this.resolvedMediaFormat;
  }

  @Override
  public @NotNull ValueMap getProperties() {
    return ValueMap.EMPTY;
  }

  @Override
  public boolean isImage() {
    return MediaFileType.isImage(getFileExtension());
  }

  @Override
  public boolean isBrowserImage() {
    return MediaFileType.isBrowserImage(getFileExtension());
  }

  @Override
  public boolean isVectorImage() {
    return MediaFileType.isVectorImage(getFileExtension());
  }

  @Override
  public boolean isDownload() {
    return !isImage();
  }

  @Override
  public long getWidth() {
    return width;
  }

  @Override
  public long getHeight() {
    return height;
  }

  @Override
  public @Nullable Date getModificationDate() {
    // not supported
    return null;
  }

  @Override
  public boolean isFallback() {
    // not supported
    return false;
  }

  @Override
  public @NotNull UriTemplate getUriTemplate(@NotNull UriTemplateType type) {
    if (!isImage() || isVectorImage()) {
      throw new UnsupportedOperationException("Unable to build URI template for " + reference.toReference());
    }
    return new NextGenDynamicMediaUriTemplate(context, type);
  }

  @Override
  public <AdapterType> @Nullable AdapterType adaptTo(@NotNull Class<AdapterType> arg0) {
    // not adaption supported
    return null;
  }

  @Override
  public String toString() {
    StringBuilder sb = new StringBuilder();
    sb.append(Objects.toString(url, "#invalid"));
    if (width > 0 || height > 0) {
      sb.append(" (").append(Long.toString(width)).append("x").append(Long.toString(height)).append(")");
    }
    return sb.toString();
  }

}