Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F3389577
D7780.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
11 KB
Referenced Files
None
Subscribers
None
D7780.diff
View Options
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;
+ }
+ }
+}
+
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Nov 30, 7:40 PM (21 h, 47 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2600569
Default Alt Text
D7780.diff (11 KB)
Attached To
Mode
D7780: [native] Implement thumbhash generation on Android
Attached
Detach File
Event Timeline
Log In to Comment