diff --git a/native/expo-modules/thumbhash/android/src/main/java/app/comm/android/thumbhash/ThumbhashModule.kt b/native/expo-modules/thumbhash/android/src/main/java/app/comm/android/thumbhash/ThumbhashModule.kt --- a/native/expo-modules/thumbhash/android/src/main/java/app/comm/android/thumbhash/ThumbhashModule.kt +++ b/native/expo-modules/thumbhash/android/src/main/java/app/comm/android/thumbhash/ThumbhashModule.kt @@ -1,14 +1,112 @@ package app.comm.android.thumbhash +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.util.Base64 +import androidx.annotation.RequiresApi +import androidx.core.graphics.scale +import androidx.core.util.component1 +import androidx.core.util.component2 +import expo.modules.core.errors.CodedException import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import thirdparty.ThumbHash +import kotlin.math.max + +// Thumbhash requires images that are max 100x100 pixels +private const val THUMB_SIZE = 100 class ThumbhashModule : Module() { override fun definition() = ModuleDefinition { Name("Thumbhash") - AsyncFunction("generateThumbHash") { - "unimplemented" + AsyncFunction("generateThumbHash", this@ThumbhashModule::generateThumbHash) + } + + // region Function implementations + + private fun generateThumbHash(photoURI: Uri): String { + val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // For API >= 28 we can use the ImageDecoder API which is more modern + // and allows to resize bitmap to desired size while loading the file + val source = ImageDecoder.createSource(this.contentResolver, photoURI) + ImageDecoder.decodeBitmap(source, this.decoderListener) + } else { + // For older devices, we use the BitmapFactory thing which is less + // flexible, but at least we can request for the ARGB_8888 to avoid + // future conversions. We rescale the bitmap afterwards + val opts = BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + this.contentResolver.openInputStream(photoURI).use { stream -> + BitmapFactory.decodeStream(stream, null, opts) + }?.let { + val w = THUMB_SIZE * it.width / max(it.width, it.height) + val h = THUMB_SIZE * it.height / max(it.width, it.height) + it.scale(w, h, filter = false) + } ?: throw BitmapDecodeFailureException(photoURI.toString()) } + + val rgba = bitmap.toRGBA() + val thumbHash = ThumbHash.rgbaToThumbHash(bitmap.width, bitmap.height, rgba) + return Base64.encodeToString(thumbHash, Base64.DEFAULT) } + + // endregion + + @RequiresApi(Build.VERSION_CODES.P) + private val decoderListener = + ImageDecoder.OnHeaderDecodedListener { imageDecoder, imageInfo, _ -> + val (w, h) = imageInfo.size + val newWidth = THUMB_SIZE * w / max(w, h) + val newHeight = THUMB_SIZE * h / max(w, h) + // this usually defaults bitmap config to ARGB_8888 + imageDecoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + imageDecoder.setTargetSize(newWidth, newHeight) + } + + private val contentResolver: ContentResolver + get() = requireNotNull(this.appContext.reactContext) { + "React Application Context is null" + }.contentResolver } + +// region Utility extension functions + +fun Bitmap.toRGBA(): ByteArray { + // ensure we're using the ARGB_8888 format + val bitmap: Bitmap = when (this.config) { + Bitmap.Config.ARGB_8888 -> this + else -> this.copy(Bitmap.Config.ARGB_8888, false) + } + val pixels = IntArray(bitmap.width * bitmap.height).also { + bitmap.getPixels(it, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + } + val bytes = ByteArray(pixels.size * 4) + var i = 0 + for (pixel in pixels) { + // Get components assuming is ARGB + val a = pixel shr 24 and 0xff + val r = pixel shr 16 and 0xff + val g = pixel shr 8 and 0xff + val b = pixel and 0xff + bytes[i++] = r.toByte() + bytes[i++] = g.toByte() + bytes[i++] = b.toByte() + bytes[i++] = a.toByte() + } + return bytes +} + +// endregion + +// region Exception definitions + +private class BitmapDecodeFailureException(uri: String) : + CodedException("Failed to decode Bitmap for URI: $uri") + +// endregion diff --git a/native/expo-modules/thumbhash/android/src/main/java/thirdparty/LICENSE.txt b/native/expo-modules/thumbhash/android/src/main/java/thirdparty/LICENSE.txt new file mode 100644 --- /dev/null +++ b/native/expo-modules/thumbhash/android/src/main/java/thirdparty/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2023 Evan Wallace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/native/expo-modules/thumbhash/android/src/main/java/thirdparty/ThumbHash.java b/native/expo-modules/thumbhash/android/src/main/java/thirdparty/ThumbHash.java new file mode 100644 --- /dev/null +++ b/native/expo-modules/thumbhash/android/src/main/java/thirdparty/ThumbHash.java @@ -0,0 +1,148 @@ +package thirdparty; + +import androidx.annotation.NonNull; + +// ThumbHash Java implementation thanks to @evanw https://github.com/evanw/thumbhash +public final class ThumbHash { + /** + * Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A. + * + * @param w The width of the input image. Must be ≤100px. + * @param h The height of the input image. Must be ≤100px. + * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. + * @return The ThumbHash as a byte array. + */ + @NonNull + public static byte[] rgbaToThumbHash(int w, int h, byte[] rgba) { + // Encoding an image larger than 100x100 is slow with no benefit + if (w > 100 || h > 100) throw new IllegalArgumentException(w + "x" + h + " doesn't fit in 100x100"); + + // Determine the average color + float avg_r = 0, avg_g = 0, avg_b = 0, avg_a = 0; + for (int i = 0, j = 0; i < w * h; i++, j += 4) { + float alpha = (rgba[j + 3] & 255) / 255.0f; + avg_r += alpha / 255.0f * (rgba[j] & 255); + avg_g += alpha / 255.0f * (rgba[j + 1] & 255); + avg_b += alpha / 255.0f * (rgba[j + 2] & 255); + avg_a += alpha; + } + if (avg_a > 0) { + avg_r /= avg_a; + avg_g /= avg_a; + avg_b /= avg_a; + } + + boolean hasAlpha = avg_a < w * h; + int l_limit = hasAlpha ? 5 : 7; // Use fewer luminance bits if there's alpha + int lx = Math.max(1, Math.round((float) (l_limit * w) / (float) Math.max(w, h))); + int ly = Math.max(1, Math.round((float) (l_limit * h) / (float) Math.max(w, h))); + float[] l = new float[w * h]; // luminance + float[] p = new float[w * h]; // yellow - blue + float[] q = new float[w * h]; // red - green + float[] a = new float[w * h]; // alpha + + // Convert the image from RGBA to LPQA (composite atop the average color) + for (int i = 0, j = 0; i < w * h; i++, j += 4) { + float alpha = (rgba[j + 3] & 255) / 255.0f; + float r = avg_r * (1.0f - alpha) + alpha / 255.0f * (rgba[j] & 255); + float g = avg_g * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 1] & 255); + float b = avg_b * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 2] & 255); + l[i] = (r + g + b) / 3.0f; + p[i] = (r + g) / 2.0f - b; + q[i] = r - g; + a[i] = alpha; + } + + // Encode using the DCT into DC (constant) and normalized AC (varying) terms + Channel l_channel = new Channel(Math.max(3, lx), Math.max(3, ly)).encode(w, h, l); + Channel p_channel = new Channel(3, 3).encode(w, h, p); + Channel q_channel = new Channel(3, 3).encode(w, h, q); + Channel a_channel = hasAlpha ? new Channel(5, 5).encode(w, h, a) : null; + + // Write the constants + boolean isLandscape = w > h; + int header24 = Math.round(63.0f * l_channel.dc) + | (Math.round(31.5f + 31.5f * p_channel.dc) << 6) + | (Math.round(31.5f + 31.5f * q_channel.dc) << 12) + | (Math.round(31.0f * l_channel.scale) << 18) + | (hasAlpha ? 1 << 23 : 0); + int header16 = (isLandscape ? ly : lx) + | (Math.round(63.0f * p_channel.scale) << 3) + | (Math.round(63.0f * q_channel.scale) << 9) + | (isLandscape ? 1 << 15 : 0); + int ac_start = hasAlpha ? 6 : 5; + int ac_count = l_channel.ac.length + p_channel.ac.length + q_channel.ac.length + + (hasAlpha ? a_channel.ac.length : 0); + byte[] hash = new byte[ac_start + (ac_count + 1) / 2]; + hash[0] = (byte) header24; + hash[1] = (byte) (header24 >> 8); + hash[2] = (byte) (header24 >> 16); + hash[3] = (byte) header16; + hash[4] = (byte) (header16 >> 8); + if (hasAlpha) hash[5] = (byte) (Math.round(15.0f * a_channel.dc) + | (Math.round(15.0f * a_channel.scale) << 4)); + + // Write the varying factors + int ac_index = 0; + ac_index = l_channel.writeTo(hash, ac_start, ac_index); + ac_index = p_channel.writeTo(hash, ac_start, ac_index); + ac_index = q_channel.writeTo(hash, ac_start, ac_index); + if (hasAlpha) a_channel.writeTo(hash, ac_start, ac_index); + return hash; + } + + private static final class Channel { + int nx; + int ny; + float dc; + float[] ac; + float scale; + + Channel(int nx, int ny) { + this.nx = nx; + this.ny = ny; + int n = 0; + for (int cy = 0; cy < ny; cy++) + for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++) + n++; + ac = new float[n]; + } + + Channel encode(int w, int h, float[] channel) { + int n = 0; + float[] fx = new float[w]; + for (int cy = 0; cy < ny; cy++) { + for (int cx = 0; cx * ny < nx * (ny - cy); cx++) { + float f = 0; + for (int x = 0; x < w; x++) + fx[x] = (float) Math.cos(Math.PI / w * cx * (x + 0.5f)); + for (int y = 0; y < h; y++) { + float fy = (float) Math.cos(Math.PI / h * cy * (y + 0.5f)); + for (int x = 0; x < w; x++) + f += channel[x + y * w] * fx[x] * fy; + } + f /= w * h; + if (cx > 0 || cy > 0) { + ac[n++] = f; + scale = Math.max(scale, Math.abs(f)); + } else { + dc = f; + } + } + } + if (scale > 0) + for (int i = 0; i < ac.length; i++) + ac[i] = 0.5f + 0.5f / scale * ac[i]; + return this; + } + + int writeTo(byte[] hash, int start, int index) { + for (float v : ac) { + hash[start + (index >> 1)] |= Math.round(15.0f * v) << ((index & 1) << 2); + index++; + } + return index; + } + } +} +