[IMPROVEMENT] Unify Compress & Scale Across Platform
@vinceglb
When trying to compress a image, Android android.graphics.Bitmap(libjpeg-turbo) . For same compression rate, final size is small. JVM javax.imageio.ImageWriter(pure java implementation) . For same compression rate, final size is medium. IOS platform.UIKit.UIImage(Apple native C/Objective-C ) . For same compression rate, final size is large. I will suggest Android still use Bitmap(since https://github.com/JetBrains/skiko/issues/983), JVM/IOS use org.jetbrains.skia.Image(which also use libjpeg-turbo). Then the output PNG/JPEG(s) look same and size are close.
Since this is just an idea, i don't really clear where should i start a pull request, I will just leave all my implementation here.
Common:
package shared.core.graphics
import kotlinx.io.Buffer
import kotlinx.io.Sink
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.files.SystemTemporaryDirectory
import kotlinx.io.readByteArray
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.Rect
import org.jetbrains.skia.Surface
import kotlin.math.roundToInt
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import org.jetbrains.skia.Image as NativeBitmap
enum class CompressFormat {
JPEG,
PNG,
WEBP
}
expect class Bitmap {
val width: Int
val height: Int
companion object {
fun load(path: String): Bitmap?
}
fun compress(format: CompressFormat, quality: Int, sink: Sink): Boolean
fun scale(width: Int, height: Int): Bitmap
}
class SkiaBitmap(val image: NativeBitmap) {
val width: Int = image.width
val height: Int = image.height
companion object {
fun load(path: String): SkiaBitmap? {
return try {
val bytes = SystemFileSystem.source(Path(path)).buffered().readByteArray()
val image = NativeBitmap.makeFromEncoded(bytes)
SkiaBitmap(image)
} catch (e: Exception) {
null
}
}
}
fun compress(format: CompressFormat, quality: Int, sink: Sink): Boolean {
val skiaFormat = when (format) {
CompressFormat.JPEG -> EncodedImageFormat.JPEG
CompressFormat.PNG -> EncodedImageFormat.PNG
CompressFormat.WEBP -> EncodedImageFormat.WEBP
}
val encodedData = image.encodeToData(skiaFormat, quality) ?: return false
return try {
sink.write(encodedData.bytes, 0, encodedData.bytes.size)
true
} catch (e: Exception) {
false
}
}
fun scale(width: Int, height: Int) = Surface.makeRasterN32Premul(width, height).run {
canvas.drawImageRect(image, Rect.makeWH(width.toFloat(), height.toFloat()))
val newImage = makeImageSnapshot()
close()
SkiaBitmap(newImage)
}
}
private fun Bitmap.shouldResize(
maxWidthPx: Int? = null,
maxHeightPx: Int? = null
): Bitmap {
var newWidth = width
var newHeight = height
if (maxWidthPx != null && newWidth > maxWidthPx) {
val aspectRatio = newHeight.toFloat() / newWidth
newWidth = maxWidthPx
newHeight = (newWidth * aspectRatio).roundToInt()
}
if (maxHeightPx != null && newHeight > maxHeightPx) {
val aspectRatio = newWidth.toFloat() / newHeight
newHeight = maxHeightPx
newWidth = (newHeight * aspectRatio).roundToInt()
}
return if (newHeight == width && newHeight == height) this else scale(newWidth, newHeight)
}
@OptIn(ExperimentalUuidApi::class)
private fun saveBufferToTempFile(buffer: Buffer) = try {
val cachePath = Path(SystemTemporaryDirectory, "bitmapCache")
SystemFileSystem.createDirectories(cachePath)
val tmpPath = Path(cachePath, "${Uuid.random()}.tmp")
SystemFileSystem.sink(tmpPath).buffered().use { sink ->
sink.write(buffer, buffer.size)
}
"$tmpPath"
} catch (e: Exception) {
""
}
fun getCompressedBitmapPath(
path: String,
compressFormat: CompressFormat = CompressFormat.JPEG,
quality: Int = 80,
maxWidthPx: Int = 1280,
maxHeightPx: Int = 1280
): String {
//1.Load bitmap from path
val bitmap = Bitmap.load(path) ?: return path
//2.Get the original file's size (bytes)
val originalSize = SystemFileSystem.metadataOrNull(Path(path))?.size ?: Long.MAX_VALUE
//3.Calculate if bitmap should resize or not
val bitmapResized = bitmap.shouldResize(maxWidthPx, maxHeightPx)
//4.Attempt to compress the (potentially resized) bitmap
val compressionBuffer = Buffer()
val compressSuccess = try {
bitmapResized.compress(compressFormat, quality, compressionBuffer)
} catch (e: Exception) {
false
}
//5.Best case: Compression was successful and the result is smaller
if (compressSuccess && compressionBuffer.size < originalSize) {
val compressedFilePath = saveBufferToTempFile(compressionBuffer)
if (compressedFilePath.isNotEmpty()) {
return compressedFilePath
}
}
//6.Fallback to original bitmap path
return path
}
Android:
package shared.core.graphics
import android.content.Context
import android.graphics.BitmapFactory
import androidx.core.graphics.scale
import androidx.core.net.toUri
import core.di.getKoinInstance
import kotlinx.io.Sink
import kotlinx.io.asOutputStream
import android.graphics.Bitmap as NativeBitmap
import android.graphics.Bitmap.CompressFormat as NativeCompressFormat
actual class Bitmap(val image: NativeBitmap) {
actual val width: Int = image.width
actual val height: Int = image.height
actual companion object {
actual fun load(path: String): Bitmap? {
val appContext = getKoinInstance<Context>()
val inputStream = appContext.contentResolver.openInputStream(path.toUri())
if (inputStream != null) {
val image = BitmapFactory.decodeStream(inputStream)
inputStream.close()
return image?.let { Bitmap(it) }
} else {
return null
}
}
}
actual fun compress(format: CompressFormat, quality: Int, sink: Sink): Boolean {
val nativeCompressFormat = when (format) {
CompressFormat.JPEG -> NativeCompressFormat.JPEG
CompressFormat.PNG -> NativeCompressFormat.PNG
CompressFormat.WEBP -> NativeCompressFormat.WEBP_LOSSY
}
val outputStream = sink.asOutputStream()
val result = image.compress(nativeCompressFormat, quality, outputStream)
outputStream.flush()
outputStream.close()
return result
}
actual fun scale(width: Int, height: Int): Bitmap {
return Bitmap(image.scale(width, height, true))
}
}
JVM/IOS
package shared.core.graphics
actual typealias Bitmap = SkiaBitmap
Hi @hmy65! That could be a good optimization! I'm interested if you have time to work on a PR for that.
- You can find the compress code in the core module
-
FileKit.nonWeb.kt contains the
FileKit.compressImage()declaration - Then the targets implement the method in FileKit.apple.kt, FileKit.android.kt, FileKit.jvm.kt