FileKit icon indicating copy to clipboard operation
FileKit copied to clipboard

[IMPROVEMENT] Unify Compress & Scale Across Platform

Open hmy65 opened this issue 4 months ago • 1 comments

@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

hmy65 avatar Sep 25 '25 08:09 hmy65

Hi @hmy65! That could be a good optimization! I'm interested if you have time to work on a PR for that.

vinceglb avatar Oct 04 '25 10:10 vinceglb