properly play media sound on image capture

- instead of using the OnImageSavedCallback, we now use the OnImageCaptureCallback and we play the sound in the onCapturedSuccess method to prevent the user from hearing the capture sound before the actual capture takes place
- add ImageSaver and ImageUtil based on CameraX implementation
- add sealed interfaces ImageCaptureOutput and VideoCaptureOutput to prevent confusion on the supported output format for images and video
- ImageSaver supports saving images with all MediaOutput.ImageCaptureOutput that can be saved to disk; MediaStoreOutput, OutputStreamMediaOutput and FileMediaOutput (unused at the moment), it throws an UnsupportedOperation when trying to save a BitmapOutput.
- with ImageSaver, we now have control of whether the Exif attributes are written to disk and so we can get rid of ExifRemover
- delete the extension method ImageProxy.toJpegByteArray as we have a similar method in ImageUtils class
This commit is contained in:
darthpaul
2022-10-08 03:07:42 +01:00
parent 9536995b79
commit cc3c2108cf
8 changed files with 576 additions and 87 deletions

View File

@ -0,0 +1,232 @@
package com.simplemobiletools.camera.helpers
import android.graphics.*
import androidx.annotation.IntRange
import androidx.camera.core.ImageProxy
import java.io.ByteArrayOutputStream
import java.io.IOException
/**
* Utility class for image related operations.
* @see androidx.camera.core.internal.utils.ImageUtil
*/
object ImageUtil {
@Throws(CodecFailedException::class)
fun imageToJpegByteArray(image: ImageProxy, jpegQuality: Int): ByteArray {
val shouldCropImage = shouldCropImage(image)
return when (image.format) {
ImageFormat.JPEG -> {
if (!shouldCropImage) {
// When cropping is unnecessary, the byte array doesn't need to be decoded and
// re-encoded again. Therefore, jpegQuality is unnecessary in this case.
jpegImageToJpegByteArray(image)
} else {
jpegImageToJpegByteArray(image, image.cropRect, jpegQuality)
}
}
ImageFormat.YUV_420_888 -> {
yuvImageToJpegByteArray(image, if (shouldCropImage) image.cropRect else null, jpegQuality)
}
else -> {
// Unrecognized image format
byteArrayOf()
}
}
}
/**
* Converts JPEG [ImageProxy] to JPEG byte array.
*/
fun jpegImageToJpegByteArray(image: ImageProxy): ByteArray {
require(image.format == ImageFormat.JPEG) { "Incorrect image format of the input image proxy: " + image.format }
val planes = image.planes
val buffer = planes[0].buffer
val data = ByteArray(buffer.capacity())
buffer.rewind()
buffer[data]
return data
}
/**
* Converts JPEG [ImageProxy] to JPEG byte array. The input JPEG image will be cropped
* by the specified crop rectangle and compressed by the specified quality value.
*/
@Throws(CodecFailedException::class)
private fun jpegImageToJpegByteArray(
image: ImageProxy,
cropRect: Rect,
@IntRange(from = 1, to = 100) jpegQuality: Int,
): ByteArray {
require(image.format == ImageFormat.JPEG) { "Incorrect image format of the input image proxy: " + image.format }
var data = jpegImageToJpegByteArray(image)
data = cropJpegByteArray(data, cropRect, jpegQuality)
return data
}
/**
* Converts YUV_420_888 [ImageProxy] to JPEG byte array. The input YUV_420_888 image
* will be cropped if a non-null crop rectangle is specified. The output JPEG byte array will
* be compressed by the specified quality value.
*/
@Throws(CodecFailedException::class)
private fun yuvImageToJpegByteArray(
image: ImageProxy,
cropRect: Rect?,
@IntRange(from = 1, to = 100) jpegQuality: Int,
): ByteArray {
require(image.format == ImageFormat.YUV_420_888) { "Incorrect image format of the input image proxy: " + image.format }
return nv21ToJpeg(
yuv_420_888toNv21(image),
image.width,
image.height,
cropRect,
jpegQuality
)
}
/**
* [android.media.Image] to NV21 byte array.
*/
private fun yuv_420_888toNv21(image: ImageProxy): ByteArray {
val yPlane = image.planes[0]
val uPlane = image.planes[1]
val vPlane = image.planes[2]
val yBuffer = yPlane.buffer
val uBuffer = uPlane.buffer
val vBuffer = vPlane.buffer
yBuffer.rewind()
uBuffer.rewind()
vBuffer.rewind()
val ySize = yBuffer.remaining()
var position = 0
val nv21 = ByteArray(ySize + image.width * image.height / 2)
// Add the full y buffer to the array. If rowStride > 1, some padding may be skipped.
for (row in 0 until image.height) {
yBuffer[nv21, position, image.width]
position += image.width
yBuffer.position(
Math.min(ySize, yBuffer.position() - image.width + yPlane.rowStride)
)
}
val chromaHeight = image.height / 2
val chromaWidth = image.width / 2
val vRowStride = vPlane.rowStride
val uRowStride = uPlane.rowStride
val vPixelStride = vPlane.pixelStride
val uPixelStride = uPlane.pixelStride
// Interleave the u and v frames, filling up the rest of the buffer. Use two line buffers to
// perform faster bulk gets from the byte buffers.
val vLineBuffer = ByteArray(vRowStride)
val uLineBuffer = ByteArray(uRowStride)
for (row in 0 until chromaHeight) {
vBuffer[vLineBuffer, 0, Math.min(vRowStride, vBuffer.remaining())]
uBuffer[uLineBuffer, 0, Math.min(uRowStride, uBuffer.remaining())]
var vLineBufferPosition = 0
var uLineBufferPosition = 0
for (col in 0 until chromaWidth) {
nv21[position++] = vLineBuffer[vLineBufferPosition]
nv21[position++] = uLineBuffer[uLineBufferPosition]
vLineBufferPosition += vPixelStride
uLineBufferPosition += uPixelStride
}
}
return nv21
}
/**
* Crops JPEG byte array with given [android.graphics.Rect].
*/
@Throws(CodecFailedException::class)
private fun cropJpegByteArray(
data: ByteArray,
cropRect: Rect,
@IntRange(from = 1, to = 100) jpegQuality: Int,
): ByteArray {
val bitmap: Bitmap
try {
val decoder = BitmapRegionDecoder.newInstance(
data, 0, data.size,
false
)
bitmap = decoder.decodeRegion(cropRect, BitmapFactory.Options())
decoder.recycle()
} catch (e: IllegalArgumentException) {
throw CodecFailedException(
"Decode byte array failed with illegal argument.$e",
CodecFailedException.FailureType.DECODE_FAILED
)
} catch (e: IOException) {
throw CodecFailedException(
"Decode byte array failed.",
CodecFailedException.FailureType.DECODE_FAILED
)
}
val out = ByteArrayOutputStream()
val success = bitmap.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
if (!success) {
throw CodecFailedException(
"Encode bitmap failed.",
CodecFailedException.FailureType.ENCODE_FAILED
)
}
bitmap.recycle()
return out.toByteArray()
}
@Throws(CodecFailedException::class)
private fun nv21ToJpeg(
nv21: ByteArray,
width: Int,
height: Int,
cropRect: Rect?,
@IntRange(from = 1, to = 100) jpegQuality: Int,
): ByteArray {
val out = ByteArrayOutputStream()
val yuv = YuvImage(nv21, ImageFormat.NV21, width, height, null)
val success = yuv.compressToJpeg(
cropRect ?: Rect(0, 0, width, height),
jpegQuality, out
)
if (!success) {
throw CodecFailedException(
"YuvImage failed to encode jpeg.",
CodecFailedException.FailureType.ENCODE_FAILED
)
}
return out.toByteArray()
}
/**
* Checks whether the image's crop rectangle is the same as the source image size.
*/
private fun shouldCropImage(image: ImageProxy): Boolean {
return shouldCropImage(
image.width, image.height, image.cropRect.width(),
image.cropRect.height()
)
}
/**
* Checks whether the image's crop rectangle is the same as the source image size.
*/
private fun shouldCropImage(
sourceWidth: Int, sourceHeight: Int, cropRectWidth: Int,
cropRectHeight: Int
): Boolean {
return sourceWidth != cropRectWidth || sourceHeight != cropRectHeight
}
/**
* Exception for error during transcoding image.
*/
class CodecFailedException internal constructor(message: String, val failureType: FailureType) : Exception(message) {
enum class FailureType {
ENCODE_FAILED, DECODE_FAILED, UNKNOWN
}
}
}