/*
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.imagepipeline.producers;
import javax.annotation.Nullable;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.Executor;
import com.facebook.common.internal.Closeables;
import com.facebook.common.internal.ImmutableMap;
import com.facebook.common.internal.Preconditions;
import com.facebook.common.internal.VisibleForTesting;
import com.facebook.common.references.CloseableReference;
import com.facebook.common.util.TriState;
import com.facebook.imageformat.ImageFormat;
import com.facebook.imagepipeline.common.ResizeOptions;
import com.facebook.imagepipeline.image.EncodedImage;
import com.facebook.imagepipeline.memory.PooledByteBuffer;
import com.facebook.imagepipeline.memory.PooledByteBufferFactory;
import com.facebook.imagepipeline.memory.PooledByteBufferOutputStream;
import com.facebook.imagepipeline.nativecode.JpegTranscoder;
import com.facebook.imagepipeline.request.ImageRequest;
import com.facebook.imageutils.BitmapUtil;
/**
* Resizes and rotates JPEG image according to the EXIF orientation data.
*
* <p> If the image is not JPEG, no transformation is applied.
* <p>Should not be used if downsampling is in use.
*/
public class ResizeAndRotateProducer implements Producer<EncodedImage> {
private static final String PRODUCER_NAME = "ResizeAndRotateProducer";
private static final String ORIGINAL_SIZE_KEY = "Original size";
private static final String REQUESTED_SIZE_KEY = "Requested size";
private static final String FRACTION_KEY = "Fraction";
@VisibleForTesting static final int DEFAULT_JPEG_QUALITY = 85;
@VisibleForTesting static final int MAX_JPEG_SCALE_NUMERATOR = JpegTranscoder.SCALE_DENOMINATOR;
@VisibleForTesting static final int MIN_TRANSFORM_INTERVAL_MS = 100;
private static final float ROUNDUP_FRACTION = 2.0f/3;
private final Executor mExecutor;
private final PooledByteBufferFactory mPooledByteBufferFactory;
private final Producer<EncodedImage> mInputProducer;
public ResizeAndRotateProducer(
Executor executor,
PooledByteBufferFactory pooledByteBufferFactory,
Producer<EncodedImage> inputProducer) {
mExecutor = Preconditions.checkNotNull(executor);
mPooledByteBufferFactory = Preconditions.checkNotNull(pooledByteBufferFactory);
mInputProducer = Preconditions.checkNotNull(inputProducer);
}
@Override
public void produceResults(
final Consumer<EncodedImage> consumer,
final ProducerContext context) {
mInputProducer.produceResults(new TransformingConsumer(consumer, context), context);
}
private class TransformingConsumer extends DelegatingConsumer<EncodedImage, EncodedImage> {
private final ProducerContext mProducerContext;
private boolean mIsCancelled;
private final JobScheduler mJobScheduler;
public TransformingConsumer(
final Consumer<EncodedImage> consumer,
final ProducerContext producerContext) {
super(consumer);
mIsCancelled = false;
mProducerContext = producerContext;
JobScheduler.JobRunnable job = new JobScheduler.JobRunnable() {
@Override
public void run(EncodedImage encodedImage, boolean isLast) {
doTransform(encodedImage, isLast);
}
};
mJobScheduler = new JobScheduler(mExecutor, job, MIN_TRANSFORM_INTERVAL_MS);
mProducerContext.addCallbacks(
new BaseProducerContextCallbacks() {
@Override
public void onIsIntermediateResultExpectedChanged() {
if (mProducerContext.isIntermediateResultExpected()) {
mJobScheduler.scheduleJob();
}
}
@Override
public void onCancellationRequested() {
mJobScheduler.clearJob();
mIsCancelled = true;
// this only works if it is safe to discard the output of previous producer
consumer.onCancellation();
}
});
}
@Override
protected void onNewResultImpl(@Nullable EncodedImage newResult, boolean isLast) {
if (mIsCancelled) {
return;
}
if (newResult == null) {
if (isLast) {
getConsumer().onNewResult(null, true);
}
return;
}
TriState shouldTransform =
shouldTransform(mProducerContext.getImageRequest(), newResult);
// ignore the intermediate result if we don't know what to do with it
if (!isLast && shouldTransform == TriState.UNSET) {
return;
}
// just forward the result if we know that it shouldn't be transformed
if (shouldTransform != TriState.YES) {
getConsumer().onNewResult(newResult, isLast);
return;
}
// we know that the result should be transformed, hence schedule it
if (!mJobScheduler.updateJob(newResult, isLast)) {
return;
}
if (isLast || mProducerContext.isIntermediateResultExpected()) {
mJobScheduler.scheduleJob();
}
}
private void doTransform(EncodedImage encodedImage, boolean isLast) {
mProducerContext.getListener().onProducerStart(mProducerContext.getId(), PRODUCER_NAME);
ImageRequest imageRequest = mProducerContext.getImageRequest();
PooledByteBufferOutputStream outputStream = mPooledByteBufferFactory.newOutputStream();
Map<String, String> extraMap = null;
EncodedImage ret = null;
InputStream is = null;
try {
int numerator = getScaleNumerator(imageRequest, encodedImage);
extraMap = getExtraMap(encodedImage, imageRequest, numerator);
is = encodedImage.getInputStream();
JpegTranscoder.transcodeJpeg(
is,
outputStream,
getRotationAngle(imageRequest, encodedImage),
numerator,
DEFAULT_JPEG_QUALITY);
CloseableReference<PooledByteBuffer> ref =
CloseableReference.of(outputStream.toByteBuffer());
try {
ret = new EncodedImage(ref);
ret.setImageFormat(ImageFormat.JPEG);
try {
ret.parseMetaData();
mProducerContext.getListener().
onProducerFinishWithSuccess(mProducerContext.getId(), PRODUCER_NAME, extraMap);
getConsumer().onNewResult(ret, isLast);
} finally {
EncodedImage.closeSafely(ret);
}
} finally {
CloseableReference.closeSafely(ref);
}
} catch (Exception e) {
mProducerContext.getListener().
onProducerFinishWithFailure(mProducerContext.getId(), PRODUCER_NAME, e, extraMap);
getConsumer().onFailure(e);
return;
} finally {
Closeables.closeQuietly(is);
outputStream.close();
}
}
private Map<String, String> getExtraMap(
EncodedImage encodedImage,
ImageRequest imageRequest,
int numerator) {
if (!mProducerContext.getListener().requiresExtraMap(mProducerContext.getId())) {
return null;
}
String originalSize = encodedImage.getWidth() + "x" + encodedImage.getHeight();
String requestedSize;
if (imageRequest.getResizeOptions() != null) {
requestedSize =
imageRequest.getResizeOptions().width + "x" + imageRequest.getResizeOptions().height;
} else {
requestedSize = "Unspecified";
}
String fraction = numerator > 0 ? numerator + "/8" : "";
return ImmutableMap.of(
ORIGINAL_SIZE_KEY, originalSize,
REQUESTED_SIZE_KEY, requestedSize,
FRACTION_KEY, fraction,
JobScheduler.QUEUE_TIME_KEY, String.valueOf(mJobScheduler.getQueuedTime()));
}
}
private static TriState shouldTransform(
ImageRequest request,
EncodedImage encodedImage) {
if (encodedImage == null || encodedImage.getImageFormat() == ImageFormat.UNKNOWN) {
return TriState.UNSET;
}
if (encodedImage.getImageFormat() != ImageFormat.JPEG) {
return TriState.NO;
}
return TriState.valueOf(
getRotationAngle(request, encodedImage) != 0 ||
shouldResize(getScaleNumerator(request, encodedImage)));
}
@VisibleForTesting static float determineResizeRatio(
ResizeOptions resizeOptions,
int width,
int height) {
if (resizeOptions == null) {
return 1.0f;
}
final float widthRatio = ((float) resizeOptions.width) / width;
final float heightRatio = ((float) resizeOptions.height) / height;
float ratio = Math.max(widthRatio, heightRatio);
// TODO: The limit is larger than this on newer devices. The problem is to get the real limit,
// you have to call Canvas.getMaximumBitmapWidth/Height on a real HW-accelerated Canvas.
if (width * ratio > BitmapUtil.MAX_BITMAP_SIZE) {
ratio = BitmapUtil.MAX_BITMAP_SIZE / width;
}
if (height * ratio > BitmapUtil.MAX_BITMAP_SIZE) {
ratio = BitmapUtil.MAX_BITMAP_SIZE / height;
}
return ratio;
}
@VisibleForTesting static int roundNumerator(float maxRatio) {
return (int) (ROUNDUP_FRACTION + maxRatio * JpegTranscoder.SCALE_DENOMINATOR);
}
private static int getScaleNumerator(
ImageRequest imageRequest,
EncodedImage encodedImage) {
final ResizeOptions resizeOptions = imageRequest.getResizeOptions();
if (resizeOptions == null) {
return JpegTranscoder.SCALE_DENOMINATOR;
}
final int rotationAngle = getRotationAngle(imageRequest, encodedImage);
final boolean swapDimensions = rotationAngle == 90 || rotationAngle == 270;
final int widthAfterRotation = swapDimensions ? encodedImage.getHeight() :
encodedImage.getWidth();
final int heightAfterRotation = swapDimensions ? encodedImage.getWidth() :
encodedImage.getHeight();
float ratio = determineResizeRatio(resizeOptions, widthAfterRotation, heightAfterRotation);
int numerator = roundNumerator(ratio);
if (numerator > MAX_JPEG_SCALE_NUMERATOR) {
return MAX_JPEG_SCALE_NUMERATOR;
}
return (numerator < 1) ? 1 : numerator;
}
private static int getRotationAngle(ImageRequest imageRequest, EncodedImage encodedImage) {
if (!imageRequest.getAutoRotateEnabled()) {
return 0;
}
int rotationAngle = encodedImage.getRotationAngle();
Preconditions.checkArgument(
rotationAngle == 0 || rotationAngle == 90 || rotationAngle == 180 || rotationAngle == 270);
return rotationAngle;
}
private static boolean shouldResize(int numerator) {
return numerator < MAX_JPEG_SCALE_NUMERATOR;
}
}