From 70ef2884f0483858f54c4fc43b83a38321316cc9 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:21:35 -0400 Subject: [PATCH] feat(mobile): jxl decoder (#32592) --- mobile/android/app/CMakeLists.txt | 13 - mobile/android/app/build.gradle | 5 - .../android/app/src/main/cpp/native_buffer.c | 52 -- .../android/app/src/main/cpp/native_image.c | 173 ----- .../app/alextran/immich/NativeBuffer.kt | 2 +- .../kotlin/app/alextran/immich/NativeImage.kt | 24 +- .../immich/images/RemoteImagesImpl.kt | 44 +- mobile/integration_test/native_core_test.dart | 11 + mobile/ios/Runner/Core/NativeCore.swift | 39 ++ .../ios/Runner/Images/ImageProcessing.swift | 2 + .../ios/Runner/Images/LocalImagesImpl.swift | 5 +- .../ios/Runner/Images/RemoteImagesImpl.swift | 40 ++ mobile/ios/Runner/Runner-Bridging-Header.h | 6 + .../infrastructure/loaders/image_request.dart | 20 +- .../loaders/local_image_request.dart | 4 +- .../loaders/remote_image_request.dart | 44 +- .../loaders/thumbhash_image_request.dart | 4 +- mobile/pubspec.yaml | 4 +- .../loaders/remote_image_request_test.dart | 18 + native/.cargo/config.toml | 10 + native/.gitignore | 3 + native/Cargo.lock | 659 ++++++++++++++++++ native/Cargo.toml | 27 + native/README.md | 13 + native/crates/native_core/Cargo.toml | 16 + native/crates/native_core/src/color.rs | 92 +++ native/crates/native_core/src/convert.rs | 15 + native/crates/native_core/src/image.rs | 202 ++++++ native/crates/native_core/src/jxl.rs | 235 +++++++ native/crates/native_core/src/lib.rs | 11 + native/crates/native_core/src/resize.rs | 103 +++ native/crates/native_core/src/rotate.rs | 78 +++ native/crates/native_core/src/thumbhash.rs | 91 +++ native/crates/native_core/tests/convert.rs | 32 + .../native_core/tests/fixtures/animated.jxl | Bin 0 -> 43 bytes .../native_core/tests/fixtures/gray.jxl | Bin 0 -> 23 bytes .../native_core/tests/fixtures/gray_icc.jxl | Bin 0 -> 218 bytes .../native_core/tests/fixtures/jpeg_icc.jxl | Bin 0 -> 919 bytes .../native_core/tests/fixtures/large.jxl | Bin 0 -> 316 bytes .../crates/native_core/tests/fixtures/p3.jxl | Bin 0 -> 27 bytes .../native_core/tests/fixtures/pq_alpha.jxl | Bin 0 -> 112 bytes .../native_core/tests/fixtures/pq_grays.jxl | Bin 0 -> 98 bytes .../native_core/tests/fixtures/srgb_alpha.jxl | Bin 0 -> 35 bytes .../native_core/tests/fixtures/xyb_icc.jxl | Bin 0 -> 297 bytes native/crates/native_core/tests/jxl.rs | 200 ++++++ native/crates/native_core/tests/rotate.rs | 32 + native/crates/native_core/tests/thumbhash.rs | 97 +++ native/crates/native_core_ffi/Cargo.toml | 23 + native/crates/native_core_ffi/build.rs | 13 + native/crates/native_core_ffi/cbindgen.toml | 3 + .../native_core_ffi/rust-toolchain.toml | 17 + native/crates/native_core_ffi/src/android.rs | 83 +++ native/crates/native_core_ffi/src/bitmap.rs | 91 +++ native/crates/native_core_ffi/src/convert.rs | 23 + native/crates/native_core_ffi/src/jxl.rs | 162 +++++ native/crates/native_core_ffi/src/lib.rs | 68 ++ native/crates/native_core_ffi/src/rotate.rs | 25 + .../crates/native_core_ffi/src/thumbhash.rs | 79 +++ native/mise.lock | 8 + native/mise.toml | 18 + native/native_core/.gitignore | 3 + native/native_core/NOTICES | 94 +++ native/native_core/analysis_options.yaml | 14 + native/native_core/ffigen.yaml | 9 + native/native_core/hook/build.dart | 63 ++ native/native_core/lib/native_core.dart | 12 + native/native_core/pubspec.yaml | 19 + native/native_core/test/native_core_test.dart | 8 + 68 files changed, 2971 insertions(+), 290 deletions(-) delete mode 100644 mobile/android/app/CMakeLists.txt delete mode 100644 mobile/android/app/src/main/cpp/native_buffer.c delete mode 100644 mobile/android/app/src/main/cpp/native_image.c create mode 100644 mobile/integration_test/native_core_test.dart create mode 100644 mobile/ios/Runner/Core/NativeCore.swift create mode 100644 native/.cargo/config.toml create mode 100644 native/.gitignore create mode 100644 native/Cargo.lock create mode 100644 native/Cargo.toml create mode 100644 native/README.md create mode 100644 native/crates/native_core/Cargo.toml create mode 100644 native/crates/native_core/src/color.rs create mode 100644 native/crates/native_core/src/convert.rs create mode 100644 native/crates/native_core/src/image.rs create mode 100644 native/crates/native_core/src/jxl.rs create mode 100644 native/crates/native_core/src/lib.rs create mode 100644 native/crates/native_core/src/resize.rs create mode 100644 native/crates/native_core/src/rotate.rs create mode 100644 native/crates/native_core/src/thumbhash.rs create mode 100644 native/crates/native_core/tests/convert.rs create mode 100644 native/crates/native_core/tests/fixtures/animated.jxl create mode 100644 native/crates/native_core/tests/fixtures/gray.jxl create mode 100644 native/crates/native_core/tests/fixtures/gray_icc.jxl create mode 100644 native/crates/native_core/tests/fixtures/jpeg_icc.jxl create mode 100644 native/crates/native_core/tests/fixtures/large.jxl create mode 100644 native/crates/native_core/tests/fixtures/p3.jxl create mode 100644 native/crates/native_core/tests/fixtures/pq_alpha.jxl create mode 100644 native/crates/native_core/tests/fixtures/pq_grays.jxl create mode 100644 native/crates/native_core/tests/fixtures/srgb_alpha.jxl create mode 100644 native/crates/native_core/tests/fixtures/xyb_icc.jxl create mode 100644 native/crates/native_core/tests/jxl.rs create mode 100644 native/crates/native_core/tests/rotate.rs create mode 100644 native/crates/native_core/tests/thumbhash.rs create mode 100644 native/crates/native_core_ffi/Cargo.toml create mode 100644 native/crates/native_core_ffi/build.rs create mode 100644 native/crates/native_core_ffi/cbindgen.toml create mode 100644 native/crates/native_core_ffi/rust-toolchain.toml create mode 100644 native/crates/native_core_ffi/src/android.rs create mode 100644 native/crates/native_core_ffi/src/bitmap.rs create mode 100644 native/crates/native_core_ffi/src/convert.rs create mode 100644 native/crates/native_core_ffi/src/jxl.rs create mode 100644 native/crates/native_core_ffi/src/lib.rs create mode 100644 native/crates/native_core_ffi/src/rotate.rs create mode 100644 native/crates/native_core_ffi/src/thumbhash.rs create mode 100644 native/mise.lock create mode 100644 native/mise.toml create mode 100644 native/native_core/.gitignore create mode 100644 native/native_core/NOTICES create mode 100644 native/native_core/analysis_options.yaml create mode 100644 native/native_core/ffigen.yaml create mode 100644 native/native_core/hook/build.dart create mode 100644 native/native_core/lib/native_core.dart create mode 100644 native/native_core/pubspec.yaml create mode 100644 native/native_core/test/native_core_test.dart diff --git a/mobile/android/app/CMakeLists.txt b/mobile/android/app/CMakeLists.txt deleted file mode 100644 index 9cad7b915..000000000 --- a/mobile/android/app/CMakeLists.txt +++ /dev/null @@ -1,13 +0,0 @@ -cmake_minimum_required(VERSION 3.12) - -set(CMAKE_C_STANDARD 17) -set(CMAKE_C_STANDARD_REQUIRED ON) - -project(native_buffer LANGUAGES C) - -add_library(native_buffer SHARED - src/main/cpp/native_buffer.c - src/main/cpp/native_image.c -) - -target_link_libraries(native_buffer jnigraphics) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 7b3217ef8..4ab2d67e4 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -79,11 +79,6 @@ android { } namespace 'app.alextran.immich' - externalNativeBuild { - cmake { - path "CMakeLists.txt" - } - } } flutter { diff --git a/mobile/android/app/src/main/cpp/native_buffer.c b/mobile/android/app/src/main/cpp/native_buffer.c deleted file mode 100644 index bed104538..000000000 --- a/mobile/android/app/src/main/cpp/native_buffer.c +++ /dev/null @@ -1,52 +0,0 @@ -#include -#include -#include - -JNIEXPORT jlong JNICALL -Java_app_alextran_immich_NativeBuffer_allocate( - JNIEnv *env, jclass clazz, jint size) { - void *ptr = malloc(size); - return (jlong) ptr; -} - -JNIEXPORT void JNICALL -Java_app_alextran_immich_NativeBuffer_free( - JNIEnv *env, jclass clazz, jlong address) { - free((void *) address); -} - -JNIEXPORT jlong JNICALL -Java_app_alextran_immich_NativeBuffer_realloc( - JNIEnv *env, jclass clazz, jlong address, jint size) { - void *ptr = realloc((void *) address, size); - return (jlong) ptr; -} - -JNIEXPORT jobject JNICALL -Java_app_alextran_immich_NativeBuffer_wrap( - JNIEnv *env, jclass clazz, jlong address, jint capacity) { - return (*env)->NewDirectByteBuffer(env, (void *) address, capacity); -} - -JNIEXPORT void JNICALL -Java_app_alextran_immich_NativeBuffer_copy( - JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) { - void *src = (*env)->GetDirectBufferAddress(env, buffer); - if (src != NULL) { - memcpy((void *) destAddress, (char *) src + offset, length); - } -} - -/** - * Creates a JNI global reference to the given object and returns its address. - * The caller is responsible for deleting the global reference when it's no longer needed. - */ -JNIEXPORT jlong JNICALL -Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) { - if (obj == NULL) { - return 0; - } - - jobject globalRef = (*env)->NewGlobalRef(env, obj); - return (jlong) globalRef; -} diff --git a/mobile/android/app/src/main/cpp/native_image.c b/mobile/android/app/src/main/cpp/native_image.c deleted file mode 100644 index 3a4f2f11a..000000000 --- a/mobile/android/app/src/main/cpp/native_image.c +++ /dev/null @@ -1,173 +0,0 @@ -#include -#include -#include -#include - -// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1. -#define TILE 32 - -// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*). -enum { - ORIENTATION_FLIP_HORIZONTAL = 2, - ORIENTATION_ROTATE_180 = 3, - ORIENTATION_FLIP_VERTICAL = 4, - ORIENTATION_TRANSPOSE = 5, - ORIENTATION_ROTATE_90 = 6, - ORIENTATION_TRANSVERSE = 7, - ORIENTATION_ROTATE_270 = 8, -}; - -// The orientations that swap width and height. Must stay in sync with affine_for's dim usage. -static int swaps_dims(int o) { - return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 || - o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE; -} - -// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the -// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout -// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on -// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows. -static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) { - switch (o) { - case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break; - case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break; - case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break; - case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break; - case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break; - case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break; - case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break; - default: *base = 0; *stepX = 1; *stepY = dw; break; - } -} - -// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated -// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay -// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on. -static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst, - int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) { - for (int ty = 0; ty < sh; ty += TILE) { - int yEnd = ty + TILE < sh ? ty + TILE : sh; - for (int tx = 0; tx < sw; tx += TILE) { - int xEnd = tx + TILE < sw ? tx + TILE : sw; - for (int sy = ty; sy < yEnd; sy++) { - const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride); - int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX; - for (int sx = tx; sx < xEnd; sx++) { - dst[idx] = srcRow[sx]; - idx += stepX; - } - } - } - } -} - -// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it -// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer -// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back. -JNIEXPORT jlong JNICALL -Java_app_alextran_immich_NativeImage_rotate( - JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) { - AndroidBitmapInfo info; - if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) { - return 0; - } - if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) { - return 0; - } - - int sw = (int) info.width; - int sh = (int) info.height; - int dw = swaps_dims(orientation) ? sh : sw; - int dh = swaps_dims(orientation) ? sw : sh; - - uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4); - if (dst == NULL) { - return 0; - } - - void *srcPixels = NULL; - if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) { - free(dst); - return 0; - } - - int64_t base, stepX, stepY; - affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY); - rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY); - - AndroidBitmap_unlockPixels(env, bitmap); - - jint dims[3] = {dw, dh, dw * 4}; - (*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims); - // Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small, - // SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst. - if ((*env)->ExceptionCheck(env)) { - free(dst); - return 0; - } - return (jlong) dst; -} - -// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's -// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap. -// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9, -// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via -// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole -// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes -// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888. -static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) { - for (int y = 0; y < h; y++) { - const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride); - uint32_t *dstRow = dst + (size_t) y * w; - for (int x = 0; x < w; x++) { - uint32_t px = srcRow[x]; - uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16; - uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16; - uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16; - uint32_t a = ((px >> 30) & 0x3) * 85u; - dstRow[x] = r | (g << 8) | (b << 16) | (a << 24); - } - } -} - -// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly -// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer -// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be -// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free. -JNIEXPORT jlong JNICALL -Java_app_alextran_immich_NativeImage_convert1010102( - JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) { - AndroidBitmapInfo info; - if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) { - return 0; - } - if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) { - return 0; - } - - int w = (int) info.width; - int h = (int) info.height; - - uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4); - if (dst == NULL) { - return 0; - } - - void *srcPixels = NULL; - if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) { - free(dst); - return 0; - } - - convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h); - - AndroidBitmap_unlockPixels(env, bitmap); - - jint dims[3] = {w, h, w * 4}; - (*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims); - if ((*env)->ExceptionCheck(env)) { - free(dst); - return 0; - } - return (jlong) dst; -} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt index 74f024185..4737c78b7 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt @@ -6,7 +6,7 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024 object NativeBuffer { init { - System.loadLibrary("native_buffer") + System.loadLibrary("native_core_ffi") } @JvmStatic diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeImage.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeImage.kt index 68feaa2d1..9d91f79a6 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeImage.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeImage.kt @@ -1,11 +1,11 @@ package app.alextran.immich import android.graphics.Bitmap +import java.nio.ByteBuffer object NativeImage { init { - // rotate() is compiled into the native_buffer shared lib (which already links jnigraphics). - System.loadLibrary("native_buffer") + System.loadLibrary("native_core_ffi") } /** @@ -26,4 +26,24 @@ object NativeImage { */ @JvmStatic external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long + + /** + * Like [convert1010102], but decodes the JPEG XL image of [length] bytes at [address], stopped by + * [cancel], downscaled to cover [targetWidth] x [targetHeight] unless either is 0 and to fit + * within [maxPixelSize]. + */ + @JvmStatic + external fun decodeJxl( + address: Long, + length: Int, + targetWidth: Int, + targetHeight: Int, + maxPixelSize: Int, + cancelled: ByteBuffer, + outInfo: IntArray + ): Long + + /** Stops the [decodeJxl] given this one-byte direct buffer. */ + @JvmStatic + external fun cancel(cancelled: ByteBuffer) } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index a5481d5b7..91dd4f985 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -10,6 +10,7 @@ import androidx.exifinterface.media.ExifInterface import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeByteBuffer +import app.alextran.immich.NativeImage import app.alextran.immich.core.HttpClientManager import kotlinx.coroutines.* import okhttp3.Cache @@ -56,10 +57,9 @@ private val RAW_MIME_TYPES = setOf( "image/x-sony-sr2", "image/x-sony-srf", "image/x3f", ) -private fun isRawMime(contentType: String?): Boolean { - val mime = contentType?.substringBefore(';')?.trim()?.lowercase() ?: return false - return mime in RAW_MIME_TYPES -} +private fun mimeType(contentType: String?) = contentType?.substringBefore(';')?.trim()?.lowercase() + +private fun isRawMime(contentType: String?) = mimeType(contentType) in RAW_MIME_TYPES class RemoteImagesImpl(context: Context) : RemoteImageApi { private val requestMap = ConcurrentHashMap() @@ -97,6 +97,42 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { return@fetch callback(CANCELLED) } + // Neither Android nor Flutter can decode JPEG XL, so this applies to encoded requests too. + if (mimeType(contentType) == "image/jxl") { + decodeExecutor.execute { + val info = IntArray(3) + // The fetch is done, so cancelling now stops the decode instead. + val cancelled = ByteBuffer.allocateDirect(1) + signal.setOnCancelListener { NativeImage.cancel(cancelled) } + val pointer = if (signal.isCanceled) 0L else NativeImage.decodeJxl( + buffer.pointer, + buffer.offset, + width?.toInt() ?: 0, + height?.toInt() ?: 0, + MAX_PIXEL_SIZE, + cancelled, + info + ) + buffer.free() + requestMap.remove(requestId) + callback( + when { + pointer != 0L -> Result.success( + mapOf( + "pointer" to pointer, + "width" to info[0].toLong(), + "height" to info[1].toLong(), + "rowBytes" to info[2].toLong() + ) + ) + signal.isCanceled -> CANCELLED + else -> Result.failure(IOException("Failed to decode JPEG XL image")) + } + ) + } + return@fetch + } + // Decode natively when the caller wants pixels: Flutter's fallback decoder copies // 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a // dedicated pool - the fetch callback threads are shared with video streaming. On any diff --git a/mobile/integration_test/native_core_test.dart b/mobile/integration_test/native_core_test.dart new file mode 100644 index 000000000..bd34e38a6 --- /dev/null +++ b/mobile/integration_test/native_core_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:native_core/native_core.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('native core loads on device', () { + expect(version(), isNotEmpty); + }); +} diff --git a/mobile/ios/Runner/Core/NativeCore.swift b/mobile/ios/Runner/Core/NativeCore.swift new file mode 100644 index 000000000..03eba1f95 --- /dev/null +++ b/mobile/ios/Runner/Core/NativeCore.swift @@ -0,0 +1,39 @@ +import Foundation + +enum NativeCore { + private static let library = dlopen("@rpath/native_core_ffi.framework/native_core_ffi", RTLD_NOW) + + private static func symbol(_ name: String, as type: T.Type) -> T? { + guard let library, let symbol = dlsym(library, name) else { return nil } + return unsafeBitCast(symbol, to: type) + } + + private static let coreThumbhash = symbol("native_core_thumbhash", as: NativeCoreThumbhashFn.self) + private static let coreJxlDecode = symbol("native_core_jxl_decode", as: NativeCoreJxlDecodeFn.self) + private static let coreCancel = symbol("native_core_cancel", as: NativeCoreCancelFn.self) + + static func thumbhash(_ hash: Data) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? { + guard let coreThumbhash else { return nil } + var width: Int32 = 0 + var height: Int32 = 0 + return hash.withUnsafeBytes { bytes in + guard let pointer = coreThumbhash(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, &width, &height) + else { return nil } + return (Int(width), Int(height), UnsafeMutableRawPointer(pointer)) + } + } + + static func decodeJxl(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? { + guard let coreJxlDecode else { return nil } + var info = NativeCoreImageInfo() + return data.withUnsafeBytes { bytes in + guard let pointer = coreJxlDecode(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info) + else { return nil } + return (Int(info.width), Int(info.height), UnsafeMutableRawPointer(pointer)) + } + } + + static func cancel(_ flag: UnsafeMutablePointer) { + coreCancel?(flag) + } +} diff --git a/mobile/ios/Runner/Images/ImageProcessing.swift b/mobile/ios/Runner/Images/ImageProcessing.swift index 686a3464a..f7ec72101 100644 --- a/mobile/ios/Runner/Images/ImageProcessing.swift +++ b/mobile/ios/Runner/Images/ImageProcessing.swift @@ -9,4 +9,6 @@ enum ImageProcessing { return q }() static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) + // Impeller shrinks each side to the GPU texture limit on its own, which squishes long images. + static let maxPixelSize = 16384 } diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 0d023ad46..4ef2d9304 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -20,7 +20,6 @@ class LocalImageApiImpl: LocalImageApi { requestOptions.version = .current return requestOptions }() - private static let maxPixelSize: CGFloat = 16384 private static let registry = RequestRegistry() @@ -111,7 +110,7 @@ class LocalImageApiImpl: LocalImageApi { let isOriginal = !(width > 0 && height > 0) let targetSize = isOriginal - ? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize) + ? CGSize(width: ImageProcessing.maxPixelSize, height: ImageProcessing.maxPixelSize) : CGSize(width: Double(width), height: Double(height)) let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill var image: UIImage? @@ -136,7 +135,7 @@ class LocalImageApiImpl: LocalImageApi { } // .fast can return larger than the target, so retry with .exact to guarantee the bound. - if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) { + if max(cgImage.width, cgImage.height) > ImageProcessing.maxPixelSize { let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions exactOptions.resizeMode = .exact image = nil diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index b4b70f0b3..ac405bf7e 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -7,15 +7,23 @@ import Photos final class RemoteImageRequest: ImageRequest { var task: URLSessionDataTask? let id: Int64 + /// Lets cancellation stop a native decode partway through. + let decodeCancelled = UnsafeMutablePointer.allocate(capacity: 1) init(id: Int64, completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) { self.id = id super.init(completion: completion) + decodeCancelled.initialize(to: false) + } + + deinit { + decodeCancelled.deallocate() } override func cancel() { super.cancel() task?.cancel() + NativeCore.cancel(decodeCancelled) } } @@ -65,6 +73,38 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) } + // Flutter can't decode JPEG XL and older iOS versions can't either, so this applies to encoded requests too. + if response?.mimeType == "image/jxl" { + ImageProcessing.queue.addOperation { + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } + + guard let (width, height, pointer) = NativeCore.decodeJxl(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else { + registry.remove(requestId: request.id) + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } + return request.completion(.failure(PigeonError(code: "", message: "Failed to decode JPEG XL image", details: nil))) + } + + if request.isCancelled { + free(pointer) + return request.completion(ImageProcessing.cancelledResult) + } + + registry.remove(requestId: request.id) + request.completion( + .success([ + "pointer": Int64(Int(bitPattern: pointer)), + "width": Int64(width), + "height": Int64(height), + "rowBytes": Int64(width * 4), + ])) + } + return + } + if encoded { let length = data.count let pointer = malloc(length)! diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h index 308a2a560..d5b7f601c 100644 --- a/mobile/ios/Runner/Runner-Bridging-Header.h +++ b/mobile/ios/Runner/Runner-Bridging-Header.h @@ -1 +1,7 @@ #import "GeneratedPluginRegistrant.h" +#include "../../../native/crates/native_core_ffi/include/native_core.h" + +// The function pointer type comes from the declaration, so nothing is retyped here. +typedef __typeof__(&native_core_thumbhash) NativeCoreThumbhashFn; +typedef __typeof__(&native_core_jxl_decode) NativeCoreJxlDecodeFn; +typedef __typeof__(&native_core_cancel) NativeCoreCancelFn; diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index f15fd01b4..1a410119c 100644 --- a/mobile/lib/infrastructure/loaders/image_request.dart +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -79,8 +79,7 @@ abstract class ImageRequest { return (codec, descriptor); } - Future _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async { - final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize); + Future _firstFrame((ui.Codec, ui.ImageDescriptor)? result) async { if (result == null) { return null; } @@ -116,7 +115,12 @@ abstract class ImageRequest { return ((width * scale).ceil(), (height * scale).ceil()); } - Future _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async { + Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromDecodedPlatformImage( + int address, + int width, + int height, + int rowBytes, + ) async { final pointer = Pointer.fromAddress(address); if (_isCancelled) { malloc.free(pointer); @@ -152,14 +156,6 @@ abstract class ImageRequest { return null; } - final frame = await codec.getNextFrame(); - descriptor.dispose(); - codec.dispose(); - if (_isCancelled) { - frame.image.dispose(); - return null; - } - - return frame; + return (codec, descriptor); } } diff --git a/mobile/lib/infrastructure/loaders/local_image_request.dart b/mobile/lib/infrastructure/loaders/local_image_request.dart index 403e5d34c..e570a24d6 100644 --- a/mobile/lib/infrastructure/loaders/local_image_request.dart +++ b/mobile/lib/infrastructure/loaders/local_image_request.dart @@ -28,7 +28,9 @@ class LocalImageRequest extends ImageRequest { return null; } - final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!); + final frame = await _firstFrame( + await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!), + ); return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 7b504697a..10127bec8 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -21,22 +21,7 @@ class RemoteImageRequest extends ImageRequest { width: decodeSize?.width.ceil(), height: decodeSize?.height.ceil(), ); - // Android falls back to encoded data if native decoding fails, so check for both shapes of the response. - final frame = switch (info) { - {'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage( - pointer, - length, - decodeSize: decodeSize, - ), - { - 'pointer': final int pointer, - 'width': final int width, - 'height': final int height, - 'rowBytes': final int rowBytes, - } => - await _fromDecodedPlatformImage(pointer, width, height, rowBytes), - _ => null, - }; + final frame = await _firstFrame(await _codecFromResponse(info, decodeSize: decodeSize)); return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } @@ -53,14 +38,29 @@ class RemoteImageRequest extends ImageRequest { width: null, height: null, ); - if (info == null) { - return null; - } - - final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null); - return codec; + final result = await _codecFromResponse(info); + return result?.$1; } + // Android falls back to encoded data if native decoding fails, and JPEG XL arrives decoded even when the encoded image + // is requested, so check for both shapes of the response. + Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromResponse(Map? info, {ui.Size? decodeSize}) => + switch (info) { + {'pointer': final int pointer, 'length': final int length} => _codecFromEncodedPlatformImage( + pointer, + length, + decodeSize: decodeSize, + ), + { + 'pointer': final int pointer, + 'width': final int width, + 'height': final int height, + 'rowBytes': final int rowBytes, + } => + _codecFromDecodedPlatformImage(pointer, width, height, rowBytes), + _ => Future.value(), + }; + @override Future _onCancelled() { return remoteImageApi.cancelRequest(requestId); diff --git a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart index 61e6a1b3a..b79b6ec5f 100644 --- a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart +++ b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart @@ -12,7 +12,9 @@ class ThumbhashImageRequest extends ImageRequest { } final Map info = await localImageApi.getThumbhash(thumbhash); - final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!); + final frame = await _firstFrame( + await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!), + ); return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0f15a6544..ceee13d69 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 3.3.5-local.8+3030501 +version: 3.3.5-local.9+3030502 environment: sdk: '>=3.12.0 <4.0.0' @@ -39,6 +39,8 @@ dependencies: hooks_riverpod: ^2.6.1 http: ^1.6.0 image_picker: ^1.2.3 + native_core: + path: ../native/native_core immich_ui: path: './packages/ui' intl: ^0.20.2 diff --git a/mobile/test/infrastructure/loaders/remote_image_request_test.dart b/mobile/test/infrastructure/loaders/remote_image_request_test.dart index a267985ab..71dd74ba2 100644 --- a/mobile/test/infrastructure/loaders/remote_image_request_test.dart +++ b/mobile/test/infrastructure/loaders/remote_image_request_test.dart @@ -76,6 +76,24 @@ void main() { expect(args[4], isNull); }); + test('accepts a decoded image for an animation request', () async { + final pointer = malloc(8)..asTypedList(8).setAll(0, [255, 0, 0, 255, 0, 0, 255, 255]); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, ( + message, + ) async { + return [ + {'pointer': pointer.address, 'width': 2, 'height': 1, 'rowBytes': 8}, + ]; + }); + + final codec = (await RemoteImageRequest(uri: 'https://example.test/animation').loadCodec())!; + final image = (await codec.getNextFrame()).image; + + expect((image.width, image.height), (2, 1)); + image.dispose(); + codec.dispose(); + }); + test('keeps portrait cover quality in a wide tile', () async { final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size(963, 642)); diff --git a/native/.cargo/config.toml b/native/.cargo/config.toml new file mode 100644 index 000000000..e7138051c --- /dev/null +++ b/native/.cargo/config.toml @@ -0,0 +1,10 @@ +# Link against minSdk (mobile/android/app/build.gradle) instead of the API 35 that native_toolchain_rust picks. +[target.aarch64-linux-android] +rustflags = ["-C", "link-arg=--target=aarch64-linux-android26"] + +# Neon on 32 bit arm is the NDK's default for C, the rust target leaves it off. +[target.armv7-linux-androideabi] +rustflags = ["-C", "link-arg=--target=armv7a-linux-androideabi26", "-C", "target-feature=+neon"] + +[target.x86_64-linux-android] +rustflags = ["-C", "link-arg=--target=x86_64-linux-android26"] diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 000000000..dd13b7e3f --- /dev/null +++ b/native/.gitignore @@ -0,0 +1,3 @@ +/target +/crates/native_core_ffi/include/native_core.h +/native_core/lib/src/bindings.g.dart diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 000000000..ed327891a --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,659 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jxl" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8379fc83d79477900c9aed78cb8370a556d834081bebcb771c6010381c4e230" +dependencies = [ + "array-init", + "byteorder", + "jxl_macros", + "jxl_simd", + "jxl_transforms", + "num-derive", + "num-traits", + "thiserror", +] + +[[package]] +name = "jxl_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb27c06a9957bd9c8e65d0aeea0db6a555a9e1c85597e284f8304015c410c87" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jxl_simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a645451b4d34b4017caf24110a14eb737e275b5d3d121bc754e67727ee65af0e" + +[[package]] +name = "jxl_transforms" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21c4b7f8957f5ba578b2e28cd5ebbc97dad58a3ad5ff26765da61e8dab8f1cad" +dependencies = [ + "jxl_simd", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "moxcms" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396077b717181ce3f9a6b66739b269b82d756be5ec0f76be0bc40a7c51775830" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "native_core" +version = "0.1.0" +dependencies = [ + "jxl", + "libc", + "moxcms", + "pic-scale", + "quick_cache", + "rayon", +] + +[[package]] +name = "native_core_ffi" +version = "0.1.0" +dependencies = [ + "cbindgen", + "jni", + "libc", + "native_core", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick_cache" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403c1a912fec895cafb223201e368234842acb9220aaf08ab042ae89ba5f135c" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 000000000..377057f54 --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,27 @@ +[workspace] +resolver = "3" +members = ["crates/native_core", "crates/native_core_ffi"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-only" + +[workspace.dependencies] +cbindgen = { version = "0.29", default-features = false } +jni = { version = "0.22", default-features = false } +jxl = { version = "0.8", default-features = false, features = ["neon"] } +libc = { version = "0.2", default-features = false } +moxcms = { version = "0.9", default-features = false, features = ["neon_luts", "neon_shaper_paths"] } +pic-scale = { version = "0.7", default-features = false, features = ["neon", "rdm"] } +quick_cache = { version = "0.7", default-features = false } +rayon = { version = "1.11", default-features = false } + +[workspace.lints.clippy] +undocumented_unsafe_blocks = "deny" + +# catch_unwind at the FFI boundary needs unwinding, so no panic = "abort" here. +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/native/README.md b/native/README.md new file mode 100644 index 000000000..1d89d7389 --- /dev/null +++ b/native/README.md @@ -0,0 +1,13 @@ +# Native core + +Rust code that ships inside the mobile app. Flutter compiles it from source through the +`native_core` build hook, so building the app needs a Rust toolchain: `mise install` +in `mobile/` sets one up, otherwise install [rustup](https://rustup.rs) once and the build +fetches the pinned toolchain itself. + +- `crates/native_core`: the shared logic +- `crates/native_core_ffi`: the C ABI, cbindgen writes `include/native_core.h` +- `native_core`: Flutter package with the build hook and the ffigen bindings + (`NOTICES` carries the licenses of the crates compiled into the core, update it with the dependencies) + +`mise run build`, `test`, `lint`, and `fmt`. The header and the Dart bindings come from `mise run //mobile:codegen:native`. diff --git a/native/crates/native_core/Cargo.toml b/native/crates/native_core/Cargo.toml new file mode 100644 index 000000000..8326eaa36 --- /dev/null +++ b/native/crates/native_core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "native_core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +jxl = { workspace = true } +libc = { workspace = true } +moxcms = { workspace = true } +pic-scale = { workspace = true } +quick_cache = { workspace = true } +rayon = { workspace = true } + +[lints] +workspace = true diff --git a/native/crates/native_core/src/color.rs b/native/crates/native_core/src/color.rs new file mode 100644 index 000000000..2929a0f70 --- /dev/null +++ b/native/crates/native_core/src/color.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub use moxcms::{CicpColorPrimaries, CicpProfile, TransferCharacteristics}; +use moxcms::{ + ColorProfile, DataColorSpace, Layout, MatrixCoefficients, RenderingIntent, + Transform16BitExecutor, TransformOptions, +}; + +use crate::image::Pixels; + +/// The colour space decoded pixels should be in. +#[derive(Clone, Copy, PartialEq)] +pub enum Target { + /// sRGB, converting and tone-mapping as needed. + Srgb, + /// The image's own space whenever CICP can describe it, otherwise sRGB. + Source, +} + +pub(crate) const SRGB: CicpProfile = CicpProfile { + color_primaries: CicpColorPrimaries::Bt709, + transfer_characteristics: TransferCharacteristics::Srgb, + matrix_coefficients: MatrixCoefficients::Identity, + full_range: true, +}; + +pub(crate) fn is_srgb(cicp: CicpProfile) -> bool { + cicp.color_primaries == SRGB.color_primaries + && cicp.transfer_characteristics == SRGB.transfer_characteristics +} + +pub(crate) fn is_hdr(cicp: CicpProfile) -> bool { + matches!( + cicp.transfer_characteristics, + TransferCharacteristics::Smpte2084 | TransferCharacteristics::Hlg + ) +} + +/// Builds the transform from an ICC profile to sRGB, and the layout of the 12-bit samples it reads. +pub(crate) fn to_srgb(icc: &[u8]) -> Option<(Arc, Layout)> { + let source = ColorProfile::new_from_slice(icc).ok()?; + let layout = match source.color_space { + DataColorSpace::Gray => Layout::GrayAlpha, + _ => Layout::Rgba, + }; + let options = TransformOptions { + // Profiles that tone-map HDR, like those jxl-rs makes, may only have a perceptual table. + rendering_intent: RenderingIntent::Perceptual, + // The fixed-point path loses precision with the narrower samples it reads. + prefer_fixed_point: false, + ..Default::default() + }; + // Going past 12-bit makes the transform's tables much slower to build for negligible benefit. + let transform = source + .create_transform_12bit(layout, &ColorProfile::new_srgb(), Layout::Rgba, options) + .ok()?; + Some((transform, layout)) +} + +/// Converts the 12-bit samples in `rgba`, `stride` bytes per row, to RGBA8 with `transform`. +pub(crate) fn convert_bands( + transform: &Transform16BitExecutor, + rgba: &mut Pixels, + stride: usize, + cancelled: &AtomicBool, +) -> Option<()> { + let (w, h) = (rgba.width(), rgba.height()); + let buf = rgba.bytes_mut(); + // Narrowing in place keeps peak memory at the wide decode plus one band. + let rows = 64; + let mut converted = vec![0u16; w * 4 * rows.min(h)]; + for y in (0..h).step_by(rows) { + if cancelled.load(Ordering::Relaxed) { + return None; + } + let n = rows.min(h - y); + let band = &buf[y * stride..][..n * stride]; + // SAFETY: any two bytes are a valid u16. + let ([], src, []) = (unsafe { band.align_to::() }) else { + return None; + }; + transform.transform(src, &mut converted[..n * w * 4]).ok()?; + // Pixels decode at least as wide as RGBA8, so this only overwrites converted rows. + let narrowed = &mut buf[y * w * 4..][..n * w * 4]; + for (dst, &src) in narrowed.iter_mut().zip(&converted) { + const NARROW: u32 = ((255 << 16) + 4095 / 2) / 4095; + *dst = ((u32::from(src) * NARROW + (1 << 15)) >> 16) as u8; + } + } + Some(()) +} diff --git a/native/crates/native_core/src/convert.rs b/native/crates/native_core/src/convert.rs new file mode 100644 index 000000000..cda337612 --- /dev/null +++ b/native/crates/native_core/src/convert.rs @@ -0,0 +1,15 @@ +/// Converts `h` rows of `w` RGBA_1010102 pixels, `stride` pixels apart in `src`, to dense RGBA_8888 +/// in `dst`. +pub fn convert_1010102(src: &[u32], stride: usize, w: usize, h: usize, dst: &mut [u32]) { + // Each 10-bit channel rounds v*255/1023 (16.16 fixed point) and alpha maps to a*85. Plain + // arithmetic on purpose: the loop auto-vectorizes and beat a lookup table on-device. + for y in 0..h { + for (out, &px) in dst[y * w..][..w].iter_mut().zip(&src[y * stride..][..w]) { + let r = ((px & 0x3ff) * 16336 + 32768) >> 16; + let g = (((px >> 10) & 0x3ff) * 16336 + 32768) >> 16; + let b = (((px >> 20) & 0x3ff) * 16336 + 32768) >> 16; + let a = (px >> 30) * 85; + *out = r | (g << 8) | (b << 16) | (a << 24); + } + } +} diff --git a/native/crates/native_core/src/image.rs b/native/crates/native_core/src/image.rs new file mode 100644 index 000000000..3e3e25293 --- /dev/null +++ b/native/crates/native_core/src/image.rs @@ -0,0 +1,202 @@ +use std::marker::PhantomData; +use std::mem::ManuallyDrop; +use std::ptr::NonNull; +use std::slice; + +use crate::color::CicpProfile; +use crate::resize::{self, Lanczos}; + +/// Decoded pixels and the colour space they are in. +pub struct Image { + pub cicp: CicpProfile, + pub samples: Samples, +} + +pub enum Samples { + U8(Pixels), + /// For HDR transfers, which need more than 8 bits. + U16(Pixels), +} + +/// Premultiplied RGBA pixels in a malloc'd buffer. +pub struct Pixels { + /// Initialized bytes from calloc or realloc that only self references. + buf: NonNull<[u8]>, + width: usize, + height: usize, + sample: PhantomData, +} + +pub(crate) trait Sample: Lanczos + Ord { + const MAX: Self; + + /// Rounds like dividing the product by MAX would. + fn scale(self, alpha: Self) -> Self; +} + +impl Sample for u8 { + const MAX: Self = u8::MAX; + + fn scale(self, alpha: Self) -> Self { + let x = u16::from(self) * u16::from(alpha) + 128; + ((x + (x >> 8)) >> 8) as u8 + } +} + +impl Sample for u16 { + const MAX: Self = u16::MAX; + + fn scale(self, alpha: Self) -> Self { + let x = u32::from(self) * u32::from(alpha) + 32768; + ((x + (x >> 16)) >> 16) as u16 + } +} + +impl Pixels { + /// Allocates pixels of u8 or u16 samples, the only kinds this module makes, with `pixel_bytes` + /// each if a decoder first writes them wider. + pub(crate) fn zeroed(width: usize, height: usize, pixel_bytes: usize) -> Option + where + T: Sample, + { + let len = width + .checked_mul(height)? + .checked_mul(pixel_bytes.max(size_of::<[T; 4]>()))?; + // SAFETY: calloc has no preconditions. + let ptr = NonNull::new(unsafe { libc::calloc(len, 1) }.cast())?; + Some(Self { + buf: NonNull::slice_from_raw_parts(ptr, len), + width, + height, + sample: PhantomData, + }) + } + + pub(crate) fn bytes_mut(&mut self) -> &mut [u8] { + // SAFETY: self owns these initialized bytes. + unsafe { self.buf.as_mut() } + } + + pub(crate) fn pixels_mut(&mut self) -> &mut [[T; 4]] { + // SAFETY: as for pixels, and self is borrowed mutably. + unsafe { slice::from_raw_parts_mut(self.buf.as_ptr().cast(), self.width * self.height) } + } + + /// Frees everything after the pixels. + fn shrink(mut self) -> Option { + let len = self.width * self.height * 4 * size_of::(); + // SAFETY: the buffer is live and the pixels fill its first len bytes. + let shrunk = unsafe { libc::realloc(self.buf.as_ptr().cast(), len) }; + // A failed realloc leaves the original buffer, which self still frees. + self.buf = NonNull::slice_from_raw_parts(NonNull::new(shrunk.cast())?, len); + Some(self) + } + + /// Premultiplies if `alpha`, then resizes to `size`, or else frees everything after the pixels. + pub(crate) fn premultiply_resize( + mut self, + alpha: bool, + size: Option<(usize, usize)>, + ) -> Option + where + T: Sample, + { + if alpha { + self.premultiply(); + } + let Some(size) = size else { + return self.shrink(); + }; + let mut out = self.resize(size)?; + if alpha { + // Lanczos3 overshoots, while premultiplied colour must stay within alpha. + out.clamp(); + } + Some(out) + } + + fn premultiply(&mut self) + where + T: Sample, + { + // Alpha scaling itself at full opacity lets this vectorize as every lane does the same work. + for px in self.pixels_mut() { + let [r, g, b, a] = *px; + *px = [r.scale(a), g.scale(a), b.scale(a), a.scale(T::MAX)]; + } + } + + /// Clamps colour to alpha. + fn clamp(&mut self) + where + T: Sample, + { + for px in self.pixels_mut() { + let a = px[3]; + *px = px.map(|c| c.min(a)); + } + } + + /// The pixels resampled to `size` with Lanczos3, in a new buffer. + fn resize(&self, (tw, th): (usize, usize)) -> Option + where + T: Sample, + { + let mut out = Self::zeroed(tw, th, size_of::<[T; 4]>())?; + resize::lanczos(self, &mut out)?; + Some(out) + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + pub fn pixels(&self) -> &[[T; 4]] { + // SAFETY: zeroed made room for at least these initialized pixels, and their u8 or u16 samples are + // valid for any bytes and aligned by malloc. + unsafe { slice::from_raw_parts(self.buf.as_ptr().cast(), self.width * self.height) } + } + + /// Hands the buffer to a C caller, who frees it with free(). + pub fn into_raw(self) -> *mut u8 { + ManuallyDrop::new(self).buf.as_ptr().cast() + } +} + +impl Drop for Pixels { + fn drop(&mut self) { + // SAFETY: self owns the buffer, which came from calloc or realloc. + unsafe { libc::free(self.buf.as_ptr().cast()) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_colour_within_alpha() { + fn check(half: T) { + let (zero, max) = (T::default(), T::MAX); + let mut px = Pixels::::zeroed(16, 16, size_of::<[T; 4]>()).unwrap(); + // Opaque black against translucent white rings both ways across the edge. + for (i, p) in px.pixels_mut().iter_mut().enumerate() { + *p = if i % 16 < 8 { + [zero, zero, zero, max] + } else { + [max, max, max, half] + }; + } + let px = px.premultiply_resize(true, Some((5, 5))).unwrap(); + for p in px.pixels() { + assert!(p[..3].iter().all(|&c| c <= p[3]), "{p:?}"); + } + } + check(128u8); + check(32768u16); + } +} diff --git a/native/crates/native_core/src/jxl.rs b/native/crates/native_core/src/jxl.rs new file mode 100644 index 000000000..afdd1957f --- /dev/null +++ b/native/crates/native_core/src/jxl.rs @@ -0,0 +1,235 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use jxl::api::{ + AlphaMode, Endianness, Event, ExtraChannel, JxlColorEncoding, JxlColorProfile, JxlColorType, + JxlDataFormat, JxlDecoder, JxlDecoderOptions, JxlOutputBuffer, JxlParallelRunner, + JxlParallelRunnerFun, JxlPixelFormat, JxlPrimaries, JxlTransferFunction, JxlWhitePoint, +}; +use moxcms::{Layout, MatrixCoefficients, Transform16BitExecutor}; +use rayon::prelude::*; + +use crate::color::{self, CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics}; +use crate::image::{Image, Pixels, Samples}; +use crate::resize; + +/// A JPEG XL image whose header has been read. +pub struct Jxl<'a> { + decoder: JxlDecoder, + input: &'a [u8], + /// The space of the output. + cicp: CicpProfile, + /// Converts wide samples to sRGB when the output is not in the source space. + transform: Option>, +} + +/// Runs on the global pool with up to this many threads, or inline with one. +struct Rayon(usize); + +impl JxlParallelRunner for Rayon { + fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> { + (0..num).into_par_iter().try_for_each(fun) + } + + fn num_threads(&self) -> usize { + self.0 + } +} + +/// Stops handing out work once the flag is set. +struct Cancellable<'a> { + pool: Rayon, + cancelled: &'a AtomicBool, +} + +impl JxlParallelRunner for Cancellable<'_> { + fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> { + self.pool.run(num, fun) + } + + fn num_threads(&self) -> usize { + self.pool.num_threads() + } + + // jxl-rs only calls this, and each of its tasks loops many items, so check cancellation here. + fn run_ordered( + &mut self, + num: usize, + max_threads: Option, + fun: &JxlParallelRunnerFun<'_>, + ) -> jxl::error::Result<()> { + self.pool.run_ordered(num, max_threads, &|i| { + if self.cancelled.load(Ordering::Relaxed) { + // Any error stops the decode. + return Err(jxl::error::Error::ApiUsageError("cancelled")); + } + fun(i) + }) + } +} + +impl<'a> Jxl<'a> { + pub fn open(mut input: &'a [u8], target: Target) -> Option { + let mut options = JxlDecoderOptions::default(); + // Colour transforms need straight alpha; premultiplying happens last. + options.alpha_mode = AlphaMode::Unpremultiplied; + let mut decoder = JxlDecoder::new(options); + let Event::BasicInfo = decoder.process(&mut input, None, None).ok()? else { + return None; + }; + let extra = decoder.basic_info()?.extra_channels.len(); + // An integer pixel format ensures the output profile below is sRGB rather than float scRGB. + decoder + .set_pixel_format(JxlPixelFormat::rgba8(extra)) + .ok()?; + let profile = decoder.output_color_profile()?; + if let Some(cicp) = cicp(profile) + && (target == Target::Source || color::is_srgb(cicp)) + { + if color::is_hdr(cicp) { + decoder + .set_pixel_format(JxlPixelFormat::rgba16(extra)) + .ok()?; + } + return Some(Self { + decoder, + input, + cicp, + transform: None, + }); + } + let (transform, layout) = color::to_srgb(&profile.try_as_icc()?)?; + decoder + .set_pixel_format(JxlPixelFormat { + color_type: match layout { + Layout::GrayAlpha => JxlColorType::GrayscaleAlpha, + _ => JxlColorType::Rgba, + }, + color_data_format: Some(JxlDataFormat::U16 { + endianness: Endianness::native(), + bit_depth: 12, + }), + extra_channel_format: vec![None; extra], + }) + .ok()?; + Some(Self { + decoder, + input, + cicp: color::SRGB, + transform: Some(transform), + }) + } + + /// Decodes the first frame unless cancelled, downscaled to cover `target` pixels and to fit + /// within `max_pixel_size`. + pub fn decode( + mut self, + target: Option<(usize, usize)>, + max_pixel_size: usize, + cancelled: &AtomicBool, + ) -> Option { + let info = self.decoder.basic_info()?; + let (w, h) = info.size; + let alpha = info + .extra_channels + .iter() + .any(|ec| ec.ec_type == ExtraChannel::Alpha); + let format = self.decoder.current_pixel_format()?; + let pixel_bytes = + format.color_type.samples_per_pixel() * format.color_data_format?.bytes_per_sample(); + let stride = w.checked_mul(pixel_bytes)?; + let size = resize::cover((w, h), target, max_pixel_size); + let samples = if color::is_hdr(self.cicp) { + let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?; + self.decode_frame(&mut rgba, stride, cancelled)?; + Samples::U16(rgba.premultiply_resize(alpha, size)?) + } else { + // The buffer is sized for the wide decode and ends up holding the RGBA8 result at its + // front. + let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?; + self.decode_frame(&mut rgba, stride, cancelled)?; + if let Some(transform) = &self.transform { + color::convert_bands(transform.as_ref(), &mut rgba, stride, cancelled)?; + } + Samples::U8(rgba.premultiply_resize(alpha, size)?) + }; + Some(Image { + cicp: self.cicp, + samples, + }) + } + + fn decode_frame( + &mut self, + rgba: &mut Pixels, + stride: usize, + cancelled: &AtomicBool, + ) -> Option<()> { + let (w, h) = (rgba.width(), rgba.height()); + let buf = rgba.bytes_mut(); + // The pool only pays off for large images, since thumbs are already decoded in parallel. + let threads = if w * h > 512 * 512 { + rayon::current_num_threads() + } else { + 1 + }; + let mut runner = Cancellable { + pool: Rayon(threads), + cancelled, + }; + loop { + let output = &mut [JxlOutputBuffer::new(buf, h, stride)]; + match self + .decoder + .process(&mut self.input, Some(output), Some(&mut runner)) + .ok()? + { + Event::FrameHeader => {} + // Resizing costs ~10% of a thumbnail decode, wasted on tiles that scrolled away + // meanwhile. + Event::FrameComplete { .. } => { + return (!cancelled.load(Ordering::Relaxed)).then_some(()); + } + _ => return None, + } + } + } +} + +/// The space of the decoded samples, if CICP can describe it. +fn cicp(profile: &JxlColorProfile) -> Option { + let JxlColorProfile::Simple(encoding) = profile else { + return None; + }; + let (primaries, transfer_function) = match encoding { + JxlColorEncoding::RgbColorSpace { + white_point: JxlWhitePoint::D65, + primaries, + transfer_function, + .. + } => (primaries, transfer_function), + // Gray samples decode to neutral RGB, which any primaries reproduce. + JxlColorEncoding::GrayscaleColorSpace { + white_point: JxlWhitePoint::D65, + transfer_function, + .. + } => (&JxlPrimaries::SRGB, transfer_function), + _ => return None, + }; + Some(CicpProfile { + color_primaries: match primaries { + JxlPrimaries::SRGB => CicpColorPrimaries::Bt709, + JxlPrimaries::BT2100 => CicpColorPrimaries::Bt2020, + JxlPrimaries::P3 => CicpColorPrimaries::Smpte432, + JxlPrimaries::Chromaticities { .. } => return None, + }, + transfer_characteristics: match transfer_function { + JxlTransferFunction::SRGB => TransferCharacteristics::Srgb, + JxlTransferFunction::PQ => TransferCharacteristics::Smpte2084, + JxlTransferFunction::HLG => TransferCharacteristics::Hlg, + _ => return None, + }, + matrix_coefficients: MatrixCoefficients::Identity, + full_range: true, + }) +} diff --git a/native/crates/native_core/src/lib.rs b/native/crates/native_core/src/lib.rs new file mode 100644 index 000000000..f5c08829f --- /dev/null +++ b/native/crates/native_core/src/lib.rs @@ -0,0 +1,11 @@ +pub mod color; +pub mod convert; +pub mod image; +pub mod jxl; +mod resize; +pub mod rotate; +pub mod thumbhash; + +pub fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} diff --git a/native/crates/native_core/src/resize.rs b/native/crates/native_core/src/resize.rs new file mode 100644 index 000000000..7ad83925c --- /dev/null +++ b/native/crates/native_core/src/resize.rs @@ -0,0 +1,103 @@ +use std::fmt::Debug; +use std::sync::{Arc, LazyLock}; + +use pic_scale::{ + ImageSize, ImageStore, ImageStoreMut, Resampling, ResamplingFunction, Scaler, WorkloadStrategy, +}; +use quick_cache::sync::Cache; + +use crate::image::Pixels; + +/// Reuse the resampling plan for a given src-dst image size pair. +type Plans = Cache<[usize; 4], Arc>>; + +const PLANS: usize = 16; + +pub(crate) trait Lanczos: Copy + Default + Debug + 'static { + fn plan(from: ImageSize, to: ImageSize) -> Option>>; + + fn cache() -> &'static Plans; +} + +impl Lanczos for u8 { + fn plan(from: ImageSize, to: ImageSize) -> Option>> { + // The 8-bit speed path brightens by up to two levels and is barely faster. + Scaler::new(ResamplingFunction::Lanczos3) + .set_workload_strategy(WorkloadStrategy::PreferQuality) + .plan_rgba_resampling(from, to, false) + .ok() + } + + fn cache() -> &'static Plans { + static CACHE: LazyLock> = LazyLock::new(|| Cache::new(PLANS)); + &CACHE + } +} + +impl Lanczos for u16 { + fn plan(from: ImageSize, to: ImageSize) -> Option>> { + Scaler::new(ResamplingFunction::Lanczos3) + .plan_rgba_resampling16(from, to, false, 16) + .ok() + } + + fn cache() -> &'static Plans { + static CACHE: LazyLock> = LazyLock::new(|| Cache::new(PLANS)); + &CACHE + } +} + +/// The size of `size` scaled down to cover `target` and to fit within `max_pixel_size`, if that +/// is smaller. +pub(crate) fn cover( + (w, h): (usize, usize), + target: Option<(usize, usize)>, + max_pixel_size: usize, +) -> Option<(usize, usize)> { + // Scales as side ratios, so the side that sets the scale lands exactly on its limit. + let fit = (max_pixel_size, w.max(h)); + let (num, den) = match target { + Some((tw, th)) => { + let cover = if tw * h >= th * w { (tw, w) } else { (th, h) }; + if cover.0 * fit.1 < fit.0 * cover.1 { + cover + } else { + fit + } + } + None => fit, + }; + (num < den).then(|| ((w * num).div_ceil(den), (h * num).div_ceil(den))) +} + +/// Resamples `src` into `dst` with Lanczos3. +pub(crate) fn lanczos(src: &Pixels, dst: &mut Pixels) -> Option<()> { + let [w, h, tw, th] = [src.width(), src.height(), dst.width(), dst.height()]; + let from = ImageStore::borrow(src.pixels().as_flattened(), w, h).ok()?; + let mut to = ImageStoreMut::borrow(dst.pixels_mut().as_flattened_mut(), tw, th).ok()?; + to.bit_depth = 8 * size_of::(); + T::cache() + .get_or_insert_with(&[w, h, tw, th], || { + T::plan(from.size(), to.size()).ok_or(()) + }) + .ok()? + .resample(&from, &mut to) + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn covers_within_max_pixel_size() { + // A 720p thumbnail for a 266 px tile and for a larger target, and panoramas past the limit. + assert_eq!(cover((960, 720), Some((266, 266)), 16384), Some((355, 266))); + assert_eq!(cover((960, 720), Some((1000, 1000)), 16384), None); + assert_eq!(cover((40000, 1000), None, 16384), Some((16384, 410))); + assert_eq!( + cover((1000, 40000), Some((20000, 20000)), 16384), + Some((410, 16384)) + ); + } +} diff --git a/native/crates/native_core/src/rotate.rs b/native/crates/native_core/src/rotate.rs new file mode 100644 index 000000000..4c0d68911 --- /dev/null +++ b/native/crates/native_core/src/rotate.rs @@ -0,0 +1,78 @@ +#[derive(Clone, Copy)] +pub enum Orientation { + Normal, + FlipHorizontal, + Rotate180, + FlipVertical, + Transpose, + Rotate90, + Transverse, + Rotate270, +} + +impl From for Orientation { + fn from(value: i32) -> Self { + match value { + 2 => Self::FlipHorizontal, + 3 => Self::Rotate180, + 4 => Self::FlipVertical, + 5 => Self::Transpose, + 6 => Self::Rotate90, + 7 => Self::Transverse, + 8 => Self::Rotate270, + _ => Self::Normal, + } + } +} + +impl Orientation { + pub fn dimensions(self, w: usize, h: usize) -> (usize, usize) { + match self { + Self::Transpose | Self::Rotate90 | Self::Transverse | Self::Rotate270 => (h, w), + _ => (w, h), + } + } +} + +/// Copies `h` rows of `w` pixels, `stride` pixels apart in `src`, into `dst` with `orientation` +/// applied. `dst` is dense: `orientation.dimensions(w, h)` pixels. +pub fn rotate( + src: &[u32], + stride: usize, + w: usize, + h: usize, + orientation: Orientation, + dst: &mut [u32], +) { + assert!(w <= stride && h.saturating_mul(stride) <= src.len() && w * h <= dst.len()); + let (dw, _) = orientation.dimensions(w, h); + let (w, h, dw) = (w as isize, h as isize, dw as isize); + // A source pixel (x, y) lands at base + x * step_x + y * step_y in the dense dw-wide output, + // the same layout as Bitmap.createBitmap(src, matrixForExifOrientation(orientation)). + let (base, step_x, step_y) = match orientation { + Orientation::FlipHorizontal => (w - 1, -1, dw), + Orientation::Rotate180 => ((h - 1) * dw + w - 1, -1, -dw), + Orientation::FlipVertical => ((h - 1) * dw, 1, -dw), + Orientation::Transpose => (0, dw, 1), + Orientation::Rotate90 => (h - 1, dw, -1), + Orientation::Transverse => ((w - 1) * dw + h - 1, -dw, -1), + Orientation::Rotate270 => ((w - 1) * dw, -dw, 1), + Orientation::Normal => (0, 1, dw), + }; + let (src, dst) = (src.as_ptr(), dst.as_mut_ptr()); + // 32x32 u32 tiles are 4KB, so the scattered writes of a 90/270 transpose stay in L1. + for ty in (0..h).step_by(32) { + for tx in (0..w).step_by(32) { + for y in ty..(ty + 32).min(h) { + // SAFETY: src holds h rows of stride pixels (asserted above). + let row = unsafe { src.add(y as usize * stride) }; + let mut idx = base + y * step_y + tx * step_x; + for x in tx..(tx + 32).min(w) { + // SAFETY: the affine mapping stays within the w*h output and x stays within its row. + unsafe { dst.add(idx as usize).write(row.add(x as usize).read()) }; + idx += step_x; + } + } + } + } +} diff --git a/native/crates/native_core/src/thumbhash.rs b/native/crates/native_core/src/thumbhash.rs new file mode 100644 index 000000000..6f39a7328 --- /dev/null +++ b/native/crates/native_core/src/thumbhash.rs @@ -0,0 +1,91 @@ +// 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. + +use std::f32::consts::PI; + +pub fn decode(hash: &[u8], rgba: &mut [u8; 32 * 32 * 4]) -> Option<(u32, u32)> { + // Read the constants + let &[h0, h1, h2, h3, h4] = hash.first_chunk()?; + let header24 = u32::from(h0) | u32::from(h1) << 8 | u32::from(h2) << 16; + let header16 = u16::from_le_bytes([h3, h4]); + let l_dc = (header24 & 63) as f32 / 63.0; + let p_dc = ((header24 >> 6) & 63) as f32 / 31.5 - 1.0; + let q_dc = ((header24 >> 12) & 63) as f32 / 31.5 - 1.0; + let has_alpha = header24 >> 23 != 0; + let is_landscape = header16 >> 15 != 0; + let long = if has_alpha { 5 } else { 7 }; + let short = usize::from(header16 & 7); + if short == 0 { + return None; + } + let lx = (if is_landscape { long } else { short }).max(3); + let ly = (if is_landscape { short } else { long }).max(3); + let alpha = if has_alpha { *hash.get(5)? } else { 15 }; + let a_dc = (alpha & 15) as f32 / 15.0; + // Read the varying factors (boost saturation by 1.25x to compensate for quantization) + let ac_start = if has_alpha { 6 } else { 5 }; + let mut ac_index = 0; + let mut channel = |nx, ny, scale| -> Option<[f32; 49]> { + let mut ac = [0.0; 49]; + let mut j = 0; + for cy in 0..ny { + for _ in (usize::from(cy == 0)..nx).take_while(|cx| cx * ny < nx * (ny - cy)) { + let value = (hash.get(ac_start + (ac_index >> 1))? >> ((ac_index & 1) * 4)) & 15; + ac[j] = (value as f32 / 7.5 - 1.0) * scale; + ac_index += 1; + j += 1; + } + } + Some(ac) + }; + let l_ac = channel(lx, ly, ((header24 >> 18) & 31) as f32 / 31.0)?; + let p_ac = channel(3, 3, ((header16 >> 3) & 63) as f32 / 63.0 * 1.25)?; + let q_ac = channel(3, 3, ((header16 >> 9) & 63) as f32 / 63.0 * 1.25)?; + let a_ac = if has_alpha { + channel(5, 5, (alpha >> 4) as f32 / 15.0)? + } else { + [0.0; 49] + }; + // Decode using the DCT into RGB + let ratio = if is_landscape { + long as f32 / short as f32 + } else { + short as f32 / long as f32 + }; + let w = (if ratio > 1.0 { 32.0 } else { 32.0 * ratio }).round() as u32; + let h = (if ratio > 1.0 { 32.0 / ratio } else { 32.0 }).round() as u32; + let channels = [(&l_ac, lx, ly), (&p_ac, 3, 3), (&q_ac, 3, 3), (&a_ac, 5, 5)]; + let count = if has_alpha { 4 } else { 3 }; + for y in 0..h { + for x in 0..w { + // Precompute the coefficients + let fx: [f32; 7] = + std::array::from_fn(|cx| (PI / w as f32 * (x as f32 + 0.5) * cx as f32).cos()); + let fy: [f32; 7] = + std::array::from_fn(|cy| (PI / h as f32 * (y as f32 + 0.5) * cy as f32).cos()); + let mut lpqa = [l_dc, p_dc, q_dc, a_dc]; + for (sum, &(ac, nx, ny)) in lpqa.iter_mut().zip(&channels[..count]) { + let mut j = 0; + for (cy, fy) in fy.iter().enumerate().take(ny) { + let fy2 = fy * 2.0; + for cx in (usize::from(cy == 0)..nx).take_while(|cx| cx * ny < nx * (ny - cy)) { + *sum += ac[j] * fx[cx] * fy2; + j += 1; + } + } + } + // Convert to RGB + let [l, p, q, a] = lpqa; + let b = l - 2.0 / 3.0 * p; + let r = (3.0 * l - b + q) / 2.0; + let g = r - q; + let i = ((y * w + x) * 4) as usize; + for (dst, value) in rgba[i..i + 4].iter_mut().zip([r, g, b, a]) { + *dst = (255.0 * value.min(1.0)).round().max(0.0) as u8; + } + } + } + Some((w, h)) +} diff --git a/native/crates/native_core/tests/convert.rs b/native/crates/native_core/tests/convert.rs new file mode 100644 index 000000000..d7a370ca9 --- /dev/null +++ b/native/crates/native_core/tests/convert.rs @@ -0,0 +1,32 @@ +use native_core::convert::convert_1010102; + +#[test] +fn channels_and_alpha() { + for (a, byte) in [(0, 0), (1, 85), (2, 170), (3, 255)] { + let src: [u32; 3] = [ + (0x0ff << 10) | (0x3ff << 20) | (a << 30), + 0x0ff | (0x3ff << 10) | (a << 30), + 0x3ff | (0x0ff << 20) | (a << 30), + ]; + let mut dst = [0; 3]; + convert_1010102(&src, 3, 3, 1, &mut dst); + assert_eq!( + dst, + [ + 0xff4000 | (byte << 24), + 0x00ff40 | (byte << 24), + 0x4000ff | (byte << 24) + ] + ); + } +} + +#[test] +fn padded_rows() { + let src: [u32; 6] = [ + 0xc00003ff, 0xc00ffc00, 0xdeadbeef, 0xfff00000, 0xffffffff, 0xdeadbeef, + ]; + let mut dst = [0; 4]; + convert_1010102(&src, 3, 2, 2, &mut dst); + assert_eq!(dst, [0xff0000ff, 0xff00ff00, 0xffff0000, 0xffffffff]); +} diff --git a/native/crates/native_core/tests/fixtures/animated.jxl b/native/crates/native_core/tests/fixtures/animated.jxl new file mode 100644 index 0000000000000000000000000000000000000000..f4cccc2814446cb17160517557b1b4508a22ac5a GIT binary patch literal 43 pcmey*#ZYjNA&H5DVG<_;g9d}QM7QW6Pe;Fc2A~KlLV)iJI{@bd3Z(!5 literal 0 HcmV?d00001 diff --git a/native/crates/native_core/tests/fixtures/gray.jxl b/native/crates/native_core/tests/fixtures/gray.jxl new file mode 100644 index 0000000000000000000000000000000000000000..eb7a009717ec190502ac05efb06a90c17aef7375 GIT binary patch literal 23 ecmey*#ZVw1V$Q_Dz{sG&;4RTDddSnUj{yKThXmmO literal 0 HcmV?d00001 diff --git a/native/crates/native_core/tests/fixtures/gray_icc.jxl b/native/crates/native_core/tests/fixtures/gray_icc.jxl new file mode 100644 index 0000000000000000000000000000000000000000..4c75718d74bbf30a97ae67c59e303d7d57fb855d GIT binary patch literal 218 zcmV<0044wb3IK2r#^1((oR|opGR5u8IU+oU_19$y289cK`@myE7fspYaxUh$h@z}e z@^tlhT*R?LL6X$<)wOJYA^Fa(pYQ)O^9T6~N$$?gYspGeTU%2{(Fvt3ouw!AR5mo{ zM)E9BO?xjDkG{gs0{Z9!(>F942m+K z@_R**$#8RkBBF>QA_|kqfPx}S?v@Y{&COaAK|wRXk0nggCcNns8^0C;?4ssI20 literal 0 HcmV?d00001 diff --git a/native/crates/native_core/tests/fixtures/jpeg_icc.jxl b/native/crates/native_core/tests/fixtures/jpeg_icc.jxl new file mode 100644 index 0000000000000000000000000000000000000000..855a98b97ad515e1d4e535d679cc6d801aeb181c GIT binary patch literal 919 zcmV;I18DpJ000b1SWF-d3Wo{+000zbba`-Ucx)g50001Lcx)g5008-Fcx-S000030 z3PV5$Z#@HYVlIHb+2h{KrqIUdCgAyaDE&$WH$JiS?turs#DXs@ar9`Y0ic+BRb48I#aWOyZ+~28tbgZ z9-Hj4%|09LwB??g?z-*18}GaYADr;Q4L=<5#3Pq{a>^^W{Bq1Q4_)-pNiW^>(@{@7 zcG+jAy>{Dg$36Gpg&&^y;*CEZ`Q)RQetPPwxBhzUvj;zX@y92>eDlvoKYjS)mw$fx z>$m@Y{PX`;FjZ9o03hV3Y{5{R{})6;DWj}Yio|$8$Y#_a!=yUUP(9di?jgqvD98s5 zIq0wh8;dM+)bI9T#|^FMk32K@B*Xh(jB&;#n|w0LDbrlD%{Swmv(Q5mU9{0hBb~I= zQ&U~F)mLMkwb)~mUAEb0qn)Q>{000F50Pq6<00000000oMBvE7>J0Z;- z1|X!s0u<-y7-M5_UW_qbj2DCM7%+jCfJfgA#vTG60r-K=^0sgSh%t1m1KE?}v$r~aZ6j#=-*cd$F2cwE^{Efup58fym2zV;72Aa=W z`@W8K%2fS-H)SkdlvGtSGAuanH($2V0q8CU)`kX?$qJ529-Rkg3nX_;kw^~dnP_;- zpv|z&@YcH!h6a_B*0qxyCZ&Aj@>KAgWS^=sLB-RJRmfwK%4KCv70*d;=Ce*x@$5Uw z>^Vv0^q~fzNPp3SeY;k#Dg)wEVOh&p&3dJvkj22r?6@eS@xRdqE{0W6H|G5|n810+n89u!b zY-a|FI&d%uFw9}_mgp9B;<~Yex8b#bQbNqdGtmw#4a|=owB5**ZN literal 0 HcmV?d00001 diff --git a/native/crates/native_core/tests/fixtures/xyb_icc.jxl b/native/crates/native_core/tests/fixtures/xyb_icc.jxl new file mode 100644 index 0000000000000000000000000000000000000000..e7e16f3df2d9911f05fa1981848b3b1fec291b46 GIT binary patch literal 297 zcmV+^0oMNi3PV5&Z#@HYVlIHb+2h{KrqIUdCgAyaDE&$WH$JiS?turs#DXs@ar9`Y0ic+BRb48I#aWOy Option { + Jxl::open(data, target)?.decode(None, 16384, &AtomicBool::new(false)) +} + +fn decode_srgb(data: &[u8]) -> Option> { + let Image { + cicp: + CicpProfile { + color_primaries: CicpColorPrimaries::Bt709, + transfer_characteristics: TransferCharacteristics::Srgb, + .. + }, + samples: Samples::U8(rgba), + } = decode(data, Target::Srgb)? + else { + panic!("not decoded to sRGB"); + }; + Some(rgba) +} + +#[test] +fn decodes_to_premultiplied_srgb() { + let cases: [(&str, &[u8], [u8; 8]); 5] = [ + ( + "sRGB with alpha", + include_bytes!("fixtures/srgb_alpha.jxl"), + [128, 0, 0, 128, 0, 0, 255, 255], + ), + ( + "Display P3", + include_bytes!("fixtures/p3.jxl"), + [255, 0, 0, 255, 255, 255, 255, 255], + ), + ( + "grayscale", + include_bytes!("fixtures/gray.jxl"), + [0, 0, 0, 255, 128, 128, 128, 255], + ), + ( + "grayscale with ICC profile", + include_bytes!("fixtures/gray_icc.jxl"), + [81, 81, 81, 255, 203, 203, 203, 255], + ), + ( + "animation", + include_bytes!("fixtures/animated.jxl"), + [255, 0, 0, 255, 255, 0, 0, 255], + ), + ]; + for (name, data, expected) in cases { + let rgba = decode_srgb(data).expect(name); + assert_eq!( + (rgba.width(), rgba.height(), rgba.pixels().as_flattened()), + (2, 1, &expected[..]), + "{name}" + ); + } +} + +#[test] +fn converts_lossy_icc_to_srgb() { + // jxl-rs renders XYB straight to sRGB, while a JPEG transcode decodes in its ICC space and + // takes our transform. + for data in [ + &include_bytes!("fixtures/xyb_icc.jxl")[..], + include_bytes!("fixtures/jpeg_icc.jxl"), + ] { + let rgba = decode_srgb(data).unwrap(); + for px in rgba.pixels() { + assert!( + px.iter() + .zip([210, 119, 65, 255]) + .all(|(&a, b)| a.abs_diff(b) <= 3), + "{px:?}" + ); + } + } +} + +#[test] +fn tone_maps_hdr() { + let rgba = decode_srgb(include_bytes!("fixtures/pq_grays.jxl")).unwrap(); + let levels: Vec = rgba + .pixels() + .iter() + .map(|px| { + assert!( + px[..3].iter().max().unwrap() - px[..3].iter().min().unwrap() <= 4, + "{px:?} is not neutral" + ); + px[1] + }) + .collect(); + assert!( + levels.is_sorted_by(|a, b| a < b) && levels[2] < 255, + "{levels:?}" + ); +} + +#[test] +fn keeps_spaces_that_cicp_describes() { + let pq = decode(include_bytes!("fixtures/pq_alpha.jxl"), Target::Source); + let Some(Image { + cicp: + CicpProfile { + color_primaries: CicpColorPrimaries::Bt2020, + transfer_characteristics: TransferCharacteristics::Smpte2084, + .. + }, + samples: Samples::U16(rgba), + }) = pq + else { + panic!("not decoded to BT.2100 PQ"); + }; + assert_eq!( + rgba.pixels(), + [[20000, 15000, 10000, 32768], [49271, 49271, 49271, 65535]] + ); + let p3 = decode(include_bytes!("fixtures/p3.jxl"), Target::Source); + let Some(Image { + cicp: + CicpProfile { + color_primaries: CicpColorPrimaries::Smpte432, + transfer_characteristics: TransferCharacteristics::Srgb, + .. + }, + samples: Samples::U8(rgba), + }) = p3 + else { + panic!("not decoded to Display P3"); + }; + assert_eq!(rgba.pixels(), [[255, 0, 0, 255], [255, 255, 255, 255]]); +} + +#[test] +fn converts_other_spaces_to_srgb() { + let data = include_bytes!("fixtures/gray_icc.jxl"); + let Some(Image { + cicp: + CicpProfile { + color_primaries: CicpColorPrimaries::Bt709, + transfer_characteristics: TransferCharacteristics::Srgb, + .. + }, + samples: Samples::U8(rgba), + }) = decode(data, Target::Source) + else { + panic!("not decoded to sRGB"); + }; + assert_eq!(rgba.pixels(), decode_srgb(data).unwrap().pixels()); +} + +#[test] +fn downscales_to_cover() { + let data = include_bytes!("fixtures/jpeg_icc.jxl"); + let Some(Image { + samples: Samples::U8(rgba), + .. + }) = + Jxl::open(data, Target::Srgb) + .unwrap() + .decode(Some((8, 4)), 16384, &AtomicBool::new(false)) + else { + panic!("not decoded to 8 bits"); + }; + // 16x16 covers 8x4 at 8x8, read through the wide decode buffer. + assert_eq!((rgba.width(), rgba.height()), (8, 8)); + for px in rgba.pixels() { + assert!( + px.iter() + .zip([210, 119, 65, 255]) + .all(|(&a, b)| a.abs_diff(b) <= 3), + "{px:?}" + ); + } +} + +#[test] +fn rejects_truncated_input() { + let data = include_bytes!("fixtures/srgb_alpha.jxl"); + assert!(decode_srgb(&data[..data.len() / 2]).is_none()); +} + +#[test] +fn stops_when_cancelled() { + let data = include_bytes!("fixtures/large.jxl"); + let decode = |cancelled| { + Jxl::open(data, Target::Srgb) + .unwrap() + .decode(None, 16384, &AtomicBool::new(cancelled)) + .is_some() + }; + assert_eq!((decode(false), decode(true)), (true, false)); +} diff --git a/native/crates/native_core/tests/rotate.rs b/native/crates/native_core/tests/rotate.rs new file mode 100644 index 000000000..4473fef72 --- /dev/null +++ b/native/crates/native_core/tests/rotate.rs @@ -0,0 +1,32 @@ +use native_core::rotate::{Orientation, rotate}; + +#[test] +fn exif_orientations() { + let src = [1u32, 2, 3, 4, 5, 6]; + let cases = [ + (1, (3, 2), [1, 2, 3, 4, 5, 6]), + (2, (3, 2), [3, 2, 1, 6, 5, 4]), + (3, (3, 2), [6, 5, 4, 3, 2, 1]), + (4, (3, 2), [4, 5, 6, 1, 2, 3]), + (5, (2, 3), [1, 4, 2, 5, 3, 6]), + (6, (2, 3), [4, 1, 5, 2, 6, 3]), + (7, (2, 3), [6, 3, 5, 2, 4, 1]), + (8, (2, 3), [3, 6, 2, 5, 1, 4]), + (9, (3, 2), [1, 2, 3, 4, 5, 6]), + ]; + for (exif, size, expected) in cases { + let orientation = Orientation::from(exif); + let mut dst = [0; 6]; + rotate(&src, 3, 3, 2, orientation, &mut dst); + assert_eq!(orientation.dimensions(3, 2), size); + assert_eq!(dst, expected, "orientation {exif}"); + } +} + +#[test] +fn padded_rows() { + let src = [1u32, 2, 3, 99, 4, 5, 6, 99]; + let mut dst = [0; 6]; + rotate(&src, 4, 3, 2, Orientation::Rotate90, &mut dst); + assert_eq!(dst, [4, 1, 5, 2, 6, 3]); +} diff --git a/native/crates/native_core/tests/thumbhash.rs b/native/crates/native_core/tests/thumbhash.rs new file mode 100644 index 000000000..1382c8cf6 --- /dev/null +++ b/native/crates/native_core/tests/thumbhash.rs @@ -0,0 +1,97 @@ +use native_core::thumbhash::decode; + +// Expected pixels come from the old Swift decoder patched to round like the Java did (it truncated), +// taken at pixels whose values sit well away from a rounding boundary, so the check is exact on any libm. +const LANDSCAPE: &[u8] = &[ + 0xdc, 0xf7, 0x0d, 0x35, 0x84, 0x85, 0x79, 0x78, 0x7f, 0x77, 0x77, 0xa5, 0x77, 0x48, 0x87, 0x66, + 0x86, 0x60, 0x57, 0x08, 0x76, +]; +const PORTRAIT: &[u8] = &[ + 0x93, 0x4a, 0x06, 0x2d, 0x06, 0x92, 0x56, 0xc3, 0x74, 0x05, 0x58, 0x67, 0xda, 0x8a, 0xb6, 0x67, + 0x94, 0x90, 0x51, 0x07, 0x19, +]; +const ALPHA: &[u8] = &[ + 0x60, 0x9a, 0x86, 0x3d, 0x0c, 0x3b, 0xb0, 0x59, 0x6c, 0x96, 0xa8, 0x45, 0x69, 0xf4, 0x84, 0xf9, + 0x0e, 0xa8, 0x27, 0x58, 0x76, 0x88, 0x70, 0x76, 0x47, +]; + +fn check(name: &str, hash: &[u8], size: (u32, u32), samples: &[(u32, u32, [u8; 4])]) { + let mut rgba = [0; 32 * 32 * 4]; + let (width, height) = decode(hash, &mut rgba).unwrap(); + assert_eq!((width, height), size, "{name}"); + for &(x, y, expected) in samples { + let i = ((y * width + x) * 4) as usize; + assert_eq!(rgba[i..i + 4], expected, "{name} pixel {x},{y}"); + } +} + +#[test] +fn matches_swift_placeholders() { + check( + "landscape", + LANDSCAPE, + (32, 23), + &[ + (0, 0, [48, 83, 154, 255]), + (11, 0, [59, 90, 156, 255]), + (20, 0, [91, 113, 163, 255]), + (29, 1, [109, 123, 156, 255]), + (1, 11, [84, 92, 96, 255]), + (10, 11, [77, 84, 89, 255]), + (20, 11, [143, 144, 141, 255]), + (30, 10, [164, 161, 153, 255]), + (1, 22, [98, 84, 35, 255]), + (9, 21, [69, 56, 12, 255]), + (19, 22, [87, 72, 30, 255]), + (30, 22, [79, 60, 13, 255]), + ], + ); + + check( + "portrait", + PORTRAIT, + (23, 32), + &[ + (0, 0, [27, 53, 53, 255]), + (5, 0, [57, 80, 87, 255]), + (14, 1, [41, 57, 57, 255]), + (22, 0, [42, 60, 37, 255]), + (0, 15, [149, 100, 11, 255]), + (7, 15, [166, 109, 28, 255]), + (14, 15, [181, 114, 19, 255]), + (22, 15, [204, 135, 10, 255]), + (0, 30, [70, 47, 0, 255]), + (7, 30, [106, 72, 18, 255]), + (13, 31, [135, 88, 19, 255]), + (22, 31, [159, 104, 0, 255]), + ], + ); + + check( + "alpha", + ALPHA, + (32, 32), + &[ + (1, 0, [227, 76, 54, 0]), + (10, 0, [147, 102, 114, 45]), + (19, 0, [130, 156, 130, 94]), + (31, 0, [192, 168, 23, 0]), + (0, 15, [232, 65, 49, 162]), + (10, 14, [142, 87, 106, 255]), + (20, 14, [141, 152, 119, 255]), + (31, 14, [231, 191, 50, 148]), + (0, 31, [255, 33, 10, 0]), + (11, 31, [213, 65, 76, 137]), + (20, 31, [209, 109, 64, 137]), + (31, 31, [255, 137, 0, 0]), + ], + ); +} + +#[test] +fn rejects_truncated_hash() { + let mut rgba = [0; 32 * 32 * 4]; + for len in 0..ALPHA.len() { + assert!(decode(&ALPHA[..len], &mut rgba).is_none(), "length {len}"); + } +} diff --git a/native/crates/native_core_ffi/Cargo.toml b/native/crates/native_core_ffi/Cargo.toml new file mode 100644 index 000000000..97f2a21d5 --- /dev/null +++ b/native/crates/native_core_ffi/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "native_core_ffi" +version.workspace = true +edition.workspace = true +license.workspace = true + +# native_toolchain_rust wants the explicit name and both crate types. +[lib] +name = "native_core_ffi" +crate-type = ["cdylib", "staticlib"] + +[dependencies] +native_core = { path = "../native_core" } +libc = { workspace = true } + +[target.'cfg(target_os = "android")'.dependencies] +jni = { workspace = true } + +[build-dependencies] +cbindgen = { workspace = true } + +[lints] +workspace = true diff --git a/native/crates/native_core_ffi/build.rs b/native/crates/native_core_ffi/build.rs new file mode 100644 index 000000000..219b607a9 --- /dev/null +++ b/native/crates/native_core_ffi/build.rs @@ -0,0 +1,13 @@ +use std::path::Path; + +fn main() { + // Files that define exported items. A directory here would make Flutter rerun the hook every build. + println!("cargo:rerun-if-changed=src/lib.rs"); + println!("cargo:rerun-if-changed=src/jxl.rs"); + println!("cargo:rerun-if-changed=src/thumbhash.rs"); + println!("cargo:rerun-if-changed=cbindgen.toml"); + let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + cbindgen::generate(&crate_dir) + .unwrap_or_else(|e| panic!("cbindgen failed: {e}")) + .write_to_file(Path::new(&crate_dir).join("include/native_core.h")); +} diff --git a/native/crates/native_core_ffi/cbindgen.toml b/native/crates/native_core_ffi/cbindgen.toml new file mode 100644 index 000000000..97a42a299 --- /dev/null +++ b/native/crates/native_core_ffi/cbindgen.toml @@ -0,0 +1,3 @@ +language = "C" +pragma_once = true +autogen_warning = "// Generated by cbindgen. Do not edit." diff --git a/native/crates/native_core_ffi/rust-toolchain.toml b/native/crates/native_core_ffi/rust-toolchain.toml new file mode 100644 index 000000000..609e305ad --- /dev/null +++ b/native/crates/native_core_ffi/rust-toolchain.toml @@ -0,0 +1,17 @@ +# Keep the channel in sync with the rust entries in native/mise.toml and mobile/mise.toml. +[toolchain] +channel = "1.99.1" +targets = [ + "aarch64-linux-android", + "armv7-linux-androideabi", + "x86_64-linux-android", + "aarch64-apple-ios", + "aarch64-apple-ios-sim", + "x86_64-apple-ios", + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + "aarch64-pc-windows-msvc", + "x86_64-pc-windows-msvc", +] diff --git a/native/crates/native_core_ffi/src/android.rs b/native/crates/native_core_ffi/src/android.rs new file mode 100644 index 000000000..05778e0a4 --- /dev/null +++ b/native/crates/native_core_ffi/src/android.rs @@ -0,0 +1,83 @@ +use jni::EnvUnowned; +use jni::errors::ThrowRuntimeExAndDefault; +use jni::objects::{JByteBuffer, JClass, JObject}; +use jni::sys::{jint, jlong, jobject}; + +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeBuffer_allocate<'caller>( + _env: EnvUnowned<'caller>, + _class: JClass<'caller>, + size: jint, +) -> jlong { + // SAFETY: malloc accepts any size. + unsafe { libc::malloc(size as usize) as jlong } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeBuffer_free<'caller>( + _env: EnvUnowned<'caller>, + _class: JClass<'caller>, + address: jlong, +) { + // SAFETY: the caller passes null or a live C allocation it owns. + unsafe { libc::free(address as *mut _) }; +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeBuffer_realloc<'caller>( + _env: EnvUnowned<'caller>, + _class: JClass<'caller>, + address: jlong, + size: jint, +) -> jlong { + // SAFETY: the caller passes null or a live C allocation it owns. + unsafe { libc::realloc(address as *mut _, size as usize) as jlong } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeBuffer_wrap<'caller>( + env: EnvUnowned<'caller>, + _class: JClass<'caller>, + address: jlong, + capacity: jint, +) -> jobject { + // jni's new_direct_byte_buffer rejects null and clears the java exception, the C did neither. + let raw = env.as_raw(); + // SAFETY: raw is the VM's JNIEnv and android JNI is at least 1.6, so the 1.4 table exists. + unsafe { ((**raw).v1_4.NewDirectByteBuffer)(raw, address as *mut _, capacity as jlong) } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeBuffer_copy<'caller>( + mut env: EnvUnowned<'caller>, + _class: JClass<'caller>, + buffer: JByteBuffer<'caller>, + dest: jlong, + offset: jint, + length: jint, +) { + env.with_env(|env| -> jni::errors::Result<()> { + if let Ok(src) = env.get_direct_buffer_address(&buffer) { + // SAFETY: the caller supplies valid, non-overlapping ranges for the copy. + unsafe { + let src = src.offset(offset as isize).cast(); + libc::memcpy(dest as *mut _, src, length as usize); + } + } + Ok(()) + }) + .resolve::(); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeBuffer_createGlobalRef<'caller>( + mut env: EnvUnowned<'caller>, + _class: JClass<'caller>, + obj: JObject<'caller>, +) -> jlong { + env.with_env(|env| -> jni::errors::Result<_> { + // Transfer the global reference to the caller without deleting it. + Ok(env.new_global_ref(obj).map_or(0, |r| r.into_raw() as jlong)) + }) + .resolve::() +} diff --git a/native/crates/native_core_ffi/src/bitmap.rs b/native/crates/native_core_ffi/src/bitmap.rs new file mode 100644 index 000000000..f974e2275 --- /dev/null +++ b/native/crates/native_core_ffi/src/bitmap.rs @@ -0,0 +1,91 @@ +use std::ffi::c_void; +use std::mem::MaybeUninit; +use std::{ptr, slice}; + +use jni::EnvUnowned; +use jni::errors::ThrowRuntimeExAndDefault; +use jni::objects::{JIntArray, JObject}; +use jni::sys::{JNIEnv, jlong, jobject}; + +pub const FORMAT_RGBA_8888: i32 = 1; +pub const FORMAT_RGBA_1010102: i32 = 10; + +const RESULT_SUCCESS: i32 = 0; + +#[repr(C)] +struct Info { + width: u32, + height: u32, + stride: u32, + format: i32, + flags: u32, +} + +#[link(name = "jnigraphics")] +unsafe extern "C" { + #[link_name = "AndroidBitmap_getInfo"] + fn get_info(env: *mut JNIEnv, bitmap: jobject, info: *mut Info) -> i32; + #[link_name = "AndroidBitmap_lockPixels"] + fn lock_pixels(env: *mut JNIEnv, bitmap: jobject, pixels: *mut *mut c_void) -> i32; + #[link_name = "AndroidBitmap_unlockPixels"] + fn unlock_pixels(env: *mut JNIEnv, bitmap: jobject) -> i32; +} + +/// Runs `f(src, stride, width, height, dst)` over the pixels of a `format` bitmap, writing into a +/// malloc'd dense buffer of `size(width, height)` pixels that Kotlin frees. Fills `out_info` with +/// {width, height, rowBytes} of that buffer. Returns 0 when the bitmap can't be handled. +pub fn transform<'caller>( + mut env: EnvUnowned<'caller>, + bitmap: JObject<'caller>, + out_info: JIntArray<'caller>, + format: i32, + size: impl Fn(usize, usize) -> (usize, usize), + f: impl FnOnce(&[u32], usize, usize, usize, &mut [u32]), +) -> jlong { + let mut dst = ptr::null_mut(); + let outcome = env.with_env(|env| -> jni::errors::Result<_> { + let raw_env = env.get_raw(); + let mut info = MaybeUninit::uninit(); + // SAFETY: JNI owns the bitmap reference and info is writable. + if unsafe { get_info(raw_env, bitmap.as_raw(), info.as_mut_ptr()) } != RESULT_SUCCESS { + return Ok(0); + } + // SAFETY: get_info initialized info on success. + let info = unsafe { info.assume_init() }; + if info.format != format { + return Ok(0); + } + let (w, h) = (info.width as usize, info.height as usize); + let stride = info.stride as usize / 4; + let (dw, dh) = size(w, h); + // SAFETY: malloc accepts the byte size of this valid Android bitmap. + dst = unsafe { libc::malloc(dw * dh * 4) }; + if dst.is_null() { + return Ok(0); + } + let mut pixels = ptr::null_mut(); + // SAFETY: the bitmap reference is valid and pixels is writable. + if unsafe { lock_pixels(raw_env, bitmap.as_raw(), &mut pixels) } != RESULT_SUCCESS { + return Ok(0); + } + // SAFETY: a locked 4 byte format bitmap holds h aligned rows of stride pixels, and the + // malloc above holds dw * dh pixels. + let (src, out) = unsafe { + ( + slice::from_raw_parts(pixels.cast::(), h * stride), + slice::from_raw_parts_mut(dst.cast::(), dw * dh), + ) + }; + f(src, stride, w, h, out); + // SAFETY: balances the successful lock above. + unsafe { unlock_pixels(raw_env, bitmap.as_raw()) }; + out_info.set_region(env, 0, &[dw as i32, dh as i32, (dw * 4) as i32])?; + Ok(dst as jlong) + }); + let result = outcome.resolve::(); + if result == 0 { + // SAFETY: dst is null or the malloc allocation that has not been returned to Kotlin. + unsafe { libc::free(dst) }; + } + result +} diff --git a/native/crates/native_core_ffi/src/convert.rs b/native/crates/native_core_ffi/src/convert.rs new file mode 100644 index 000000000..57a1a72e6 --- /dev/null +++ b/native/crates/native_core_ffi/src/convert.rs @@ -0,0 +1,23 @@ +use jni::EnvUnowned; +use jni::objects::{JClass, JIntArray, JObject}; +use jni::sys::jlong; +use native_core::convert::convert_1010102; + +use super::bitmap::{self, FORMAT_RGBA_1010102}; + +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeImage_convert1010102<'caller>( + env: EnvUnowned<'caller>, + _class: JClass<'caller>, + bitmap: JObject<'caller>, + out_info: JIntArray<'caller>, +) -> jlong { + bitmap::transform( + env, + bitmap, + out_info, + FORMAT_RGBA_1010102, + |w, h| (w, h), + convert_1010102, + ) +} diff --git a/native/crates/native_core_ffi/src/jxl.rs b/native/crates/native_core_ffi/src/jxl.rs new file mode 100644 index 000000000..f970a45d6 --- /dev/null +++ b/native/crates/native_core_ffi/src/jxl.rs @@ -0,0 +1,162 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{ptr, slice}; + +use libc::size_t; + +#[cfg(target_os = "android")] +use jni::{ + EnvUnowned, + errors::ThrowRuntimeExAndDefault, + objects::{JByteBuffer, JClass, JIntArray}, + sys::{jint, jlong}, +}; +use native_core::color::Target; +use native_core::image::{Image, Samples}; +use native_core::jxl::Jxl; + +use super::guard; + +/// The size and colour space of decoded pixels. +#[repr(C)] +#[derive(Default)] +pub struct NativeCoreImageInfo { + pub width: i32, + pub height: i32, + /// 8 or 16, for samples in native byte order. + pub bit_depth: u8, + /// ITU-T H.273 colour primaries. + pub primaries: u8, + /// ITU-T H.273 transfer characteristics. + pub transfer: u8, +} + +/// Decodes a JPEG XL image to premultiplied RGBA in sRGB, or with `keep_space`, in the image's own +/// space when it has BT.709, BT.2020 or Display P3 primaries with the sRGB, PQ or HLG transfer. +/// Unless either is 0, it is downscaled to cover `target_width` x `target_height`, and it never +/// exceeds `max_pixel_size` pixels per side. `info` gets the size and space. The caller frees the +/// returned buffer with free(). Returns null on failure, or once `native_core_cancel` was called on +/// `cancelled`. +/// +/// # Safety +/// `data` must be null or readable for `len` bytes, at most `isize::MAX`. +/// `cancelled` must be null or valid for the call and only changed through `native_core_cancel`. +/// `info` must be null or a valid writable pointer, outside `data`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn native_core_jxl_decode( + data: *const u8, + len: size_t, + keep_space: bool, + target_width: u32, + target_height: u32, + max_pixel_size: u32, + cancelled: *mut bool, + info: *mut NativeCoreImageInfo, +) -> *mut u8 { + guard(ptr::null_mut(), || { + if data.is_null() || cancelled.is_null() || info.is_null() { + return ptr::null_mut(); + } + // SAFETY: the caller keeps the flag alive for the call and only changes it atomically. + let cancelled = unsafe { AtomicBool::from_ptr(cancelled) }; + // SAFETY: the caller provides a buffer readable for len bytes. + let input = unsafe { slice::from_raw_parts(data, len) }; + let target = if keep_space { + Target::Source + } else { + Target::Srgb + }; + let cover = (target_width > 0 && target_height > 0) + .then_some((target_width as usize, target_height as usize)); + let Some(Image { cicp, samples }) = Jxl::open(input, target) + .and_then(|image| image.decode(cover, max_pixel_size as usize, cancelled)) + else { + return ptr::null_mut(); + }; + let (width, height, bit_depth, pixels) = match samples { + Samples::U8(rgba) => (rgba.width(), rgba.height(), 8, rgba.into_raw()), + Samples::U16(rgba) => (rgba.width(), rgba.height(), 16, rgba.into_raw()), + }; + // SAFETY: the output is writable. + unsafe { + *info = NativeCoreImageInfo { + width: width as i32, + height: height as i32, + bit_depth, + primaries: cicp.color_primaries as u8, + transfer: cicp.transfer_characteristics as u8, + }; + } + pixels + }) +} + +/// Asks the decode given `flag` to stop early. +/// +/// # Safety +/// `flag` must be null or valid and otherwise only read by `native_core_jxl_decode`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) { + if !flag.is_null() { + // SAFETY: the caller guarantees the flag is valid and only accessed atomically. + unsafe { AtomicBool::from_ptr(flag) }.store(true, Ordering::Relaxed); + } +} + +#[cfg(target_os = "android")] +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeImage_decodeJxl<'caller>( + mut env: EnvUnowned<'caller>, + _class: JClass<'caller>, + data: jlong, + length: jint, + target_width: jint, + target_height: jint, + max_pixel_size: jint, + cancelled: JByteBuffer<'caller>, + info: JIntArray<'caller>, +) -> jlong { + env.with_env(|env| -> jni::errors::Result { + let flag = env.get_direct_buffer_address(&cancelled)?.cast::(); + let mut decoded = NativeCoreImageInfo::default(); + // SAFETY: Kotlin passes a live native buffer of `length` bytes and a direct buffer it only + // changes through `cancel`, and the output is valid. + let rgba = unsafe { + native_core_jxl_decode( + data as *const u8, + length as size_t, + false, + target_width as u32, + target_height as u32, + max_pixel_size as u32, + flag, + &mut decoded, + ) + }; + if !rgba.is_null() + && let Err(err) = + info.set_region(env, 0, &[decoded.width, decoded.height, decoded.width * 4]) + { + // SAFETY: this malloc buffer has not been transferred to Kotlin. + unsafe { libc::free(rgba.cast()) }; + return Err(err); + } + Ok(rgba as jlong) + }) + .resolve::() +} + +#[cfg(target_os = "android")] +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeImage_cancel<'caller>( + mut env: EnvUnowned<'caller>, + _class: JClass<'caller>, + flag: JByteBuffer<'caller>, +) { + env.with_env(|env| -> jni::errors::Result<()> { + let flag = env.get_direct_buffer_address(&flag)?.cast::(); + // SAFETY: the buffer is the live one-byte flag Kotlin passed to `decodeJxl`. + unsafe { native_core_cancel(flag) }; + Ok(()) + }) + .resolve::(); +} diff --git a/native/crates/native_core_ffi/src/lib.rs b/native/crates/native_core_ffi/src/lib.rs new file mode 100644 index 000000000..864736d96 --- /dev/null +++ b/native/crates/native_core_ffi/src/lib.rs @@ -0,0 +1,68 @@ +use std::ffi::{CString, c_char}; +use std::panic::{self, UnwindSafe}; +use std::ptr; + +#[cfg(target_os = "android")] +mod android; +/// cbindgen:ignore +#[cfg(target_os = "android")] +mod bitmap; +#[cfg(target_os = "android")] +mod convert; +mod jxl; +#[cfg(target_os = "android")] +mod rotate; +mod thumbhash; + +/// Returns the core version as a C string. Free it with `native_core_free_string`. +#[unsafe(no_mangle)] +pub extern "C" fn native_core_version() -> *mut c_char { + guard(ptr::null_mut(), || { + match CString::new(native_core::version()) { + Ok(s) => s.into_raw(), + Err(_) => ptr::null_mut(), + } + }) +} + +/// Releases a string returned by this library. Null is a no-op. +/// +/// # Safety +/// `ptr` must come from this library and must not be freed twice. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn native_core_free_string(ptr: *mut c_char) { + if ptr.is_null() { + return; + } + guard((), || { + // SAFETY: the caller guarantees `ptr` came from `CString::into_raw` in this library. + drop(unsafe { CString::from_raw(ptr) }); + }); +} + +fn guard(fallback: T, f: impl FnOnce() -> T + UnwindSafe) -> T { + panic::catch_unwind(f).unwrap_or(fallback) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CStr; + + #[test] + fn version_roundtrips() { + let p = native_core_version(); + assert!(!p.is_null()); + // SAFETY: `p` is a C string from this library. + let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap(); + assert_eq!(s, native_core::version()); + // SAFETY: `p` is freed exactly once. + unsafe { native_core_free_string(p) }; + } + + #[test] + fn free_null_is_noop() { + // SAFETY: null is allowed by the contract. + unsafe { native_core_free_string(ptr::null_mut()) }; + } +} diff --git a/native/crates/native_core_ffi/src/rotate.rs b/native/crates/native_core_ffi/src/rotate.rs new file mode 100644 index 000000000..978cf26a6 --- /dev/null +++ b/native/crates/native_core_ffi/src/rotate.rs @@ -0,0 +1,25 @@ +use jni::EnvUnowned; +use jni::objects::{JClass, JIntArray, JObject}; +use jni::sys::{jint, jlong}; +use native_core::rotate::{Orientation, rotate}; + +use super::bitmap::{self, FORMAT_RGBA_8888}; + +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_NativeImage_rotate<'caller>( + env: EnvUnowned<'caller>, + _class: JClass<'caller>, + bitmap: JObject<'caller>, + orientation: jint, + out_info: JIntArray<'caller>, +) -> jlong { + let orientation: Orientation = orientation.into(); + bitmap::transform( + env, + bitmap, + out_info, + FORMAT_RGBA_8888, + |w, h| orientation.dimensions(w, h), + |src, stride, w, h, dst| rotate(src, stride, w, h, orientation, dst), + ) +} diff --git a/native/crates/native_core_ffi/src/thumbhash.rs b/native/crates/native_core_ffi/src/thumbhash.rs new file mode 100644 index 000000000..57b2cc962 --- /dev/null +++ b/native/crates/native_core_ffi/src/thumbhash.rs @@ -0,0 +1,79 @@ +use std::{ptr, slice}; + +use libc::size_t; + +#[cfg(target_os = "android")] +use jni::{ + EnvUnowned, + errors::ThrowRuntimeExAndDefault, + objects::{JByteArray, JClass, JIntArray}, + sys::jlong, +}; +use native_core::thumbhash; + +use super::guard; + +/// Decodes a thumbhash to RGBA. The caller frees the returned buffer with free(). +/// Returns null on failure. +/// +/// # Safety +/// `hash` must be null or readable for `len` bytes, at most `isize::MAX`. +/// `width` and `height` must be null or valid writable pointers, outside `hash`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn native_core_thumbhash( + hash: *const u8, + len: size_t, + width: *mut i32, + height: *mut i32, +) -> *mut u8 { + guard(ptr::null_mut(), || { + if hash.is_null() || width.is_null() || height.is_null() { + return ptr::null_mut(); + } + // SAFETY: the caller provides a buffer readable for len bytes. + let hash = unsafe { slice::from_raw_parts(hash, len) }; + // SAFETY: calloc accepts any size. + let rgba = unsafe { libc::calloc(1, 32 * 32 * 4).cast::<[u8; 32 * 32 * 4]>() }; + if rgba.is_null() { + return ptr::null_mut(); + } + // SAFETY: calloc returned that many zeroed bytes that nothing else references. + let Some((w, h)) = thumbhash::decode(hash, unsafe { &mut *rgba }) else { + // SAFETY: the buffer has not been returned to the caller. + unsafe { libc::free(rgba.cast()) }; + return ptr::null_mut(); + }; + // SAFETY: the outputs are writable. + unsafe { + *width = w as i32; + *height = h as i32; + } + rgba.cast() + }) +} + +#[cfg(target_os = "android")] +#[unsafe(no_mangle)] +pub extern "system" fn Java_app_alextran_immich_images_ThumbHash_decode<'caller>( + mut env: EnvUnowned<'caller>, + _class: JClass<'caller>, + hash: JByteArray<'caller>, + info: JIntArray<'caller>, +) -> jlong { + env.with_env(|env| -> jni::errors::Result { + let hash = env.convert_byte_array(&hash)?; + let (mut width, mut height) = (0, 0); + // SAFETY: the hash bytes and both output pointers are valid for this call. + let rgba = + unsafe { native_core_thumbhash(hash.as_ptr(), hash.len(), &mut width, &mut height) }; + if !rgba.is_null() + && let Err(err) = info.set_region(env, 0, &[width, height, width * 4]) + { + // SAFETY: this malloc buffer has not been transferred to Dart. + unsafe { libc::free(rgba.cast()) }; + return Err(err); + } + Ok(rgba as jlong) + }) + .resolve::() +} diff --git a/native/mise.lock b/native/mise.lock new file mode 100644 index 000000000..165200f84 --- /dev/null +++ b/native/mise.lock @@ -0,0 +1,8 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html + +[[tools.rust]] +version = "1.99.1" +backend = "core:rust" + +[tools.rust.options] +components = "clippy,rustfmt" diff --git a/native/mise.toml b/native/mise.toml new file mode 100644 index 000000000..c4a8cc984 --- /dev/null +++ b/native/mise.toml @@ -0,0 +1,18 @@ +[tools] +rust = { version = "1.99.1", components = "rustfmt,clippy" } # keep in sync with crates/native_core_ffi/rust-toolchain.toml + +[tasks.build] +description = "Build the native crates for the host" +run = "cargo build --workspace" + +[tasks.test] +description = "Run the Rust tests" +run = "cargo test --workspace --locked" + +[tasks.fmt] +description = "Check Rust formatting" +run = "cargo fmt --all --check" + +[tasks.lint] +description = "Run clippy with warnings as errors" +run = "cargo clippy --workspace --all-targets --locked -- -D warnings" diff --git a/native/native_core/.gitignore b/native/native_core/.gitignore new file mode 100644 index 000000000..61608efad --- /dev/null +++ b/native/native_core/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +build/ +pubspec.lock diff --git a/native/native_core/NOTICES b/native/native_core/NOTICES new file mode 100644 index 000000000..7fd3d7d12 --- /dev/null +++ b/native/native_core/NOTICES @@ -0,0 +1,94 @@ +array-init +byteorder +bytes +cfg-if +combine +crossbeam-deque +crossbeam-epoch +crossbeam-utils +either +equivalent +hashbrown +jni +jni-sys +libc +log +memchr +num-traits +quick_cache +rayon +rayon-core +simd_cesu8 +simdutf8 +thiserror + +Copyright (c) 2018 Carl Lerche +Copyright (c) 2014 Alex Crichton +Copyright (c) 2015 Markus Westerlind +Copyright (c) 2016 Prevoty, Inc. and jni-rs contributors +Copyright (c) 2015 The rust-jni-sys Developers +Copyright (c) The Rust Project Developers +Copyright (c) 2014 The Rust Project Developers +Copyright (c) 2015 Andrew Gallant +Copyright (c) 2017-2020 The array-init developers +Copyright (c) 2019 The Crossbeam Project Developers +Copyright (c) 2015 +Copyright (c) 2010 The Rust Project Developers +Copyright (c) 2016--2023 +Copyright (c) 2016 Amanieu d'Antras +Copyright (c) 2022 Arthur Silva + +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. + +-------------------------------------------------------------------------------- +jxl +jxl_simd +jxl_transforms +moxcms +pic-scale +pxfm + +Copyright (c) the JPEG XL Project Authors. +Copyright (c) Radzivon Bartoshyk. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/native/native_core/analysis_options.yaml b/native/native_core/analysis_options.yaml new file mode 100644 index 000000000..a26975268 --- /dev/null +++ b/native/native_core/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:flutter_lints/flutter.yaml + +formatter: + page_width: 120 + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/native/native_core/ffigen.yaml b/native/native_core/ffigen.yaml new file mode 100644 index 000000000..c55389e27 --- /dev/null +++ b/native/native_core/ffigen.yaml @@ -0,0 +1,9 @@ +# The asset id must match assetName in hook/build.dart. +ffi-native: + asset-id: 'package:native_core/src/bindings.g.dart' +output: 'lib/src/bindings.g.dart' +headers: + entry-points: + - '../crates/native_core_ffi/include/native_core.h' + include-directives: + - '**/native_core.h' diff --git a/native/native_core/hook/build.dart b/native/native_core/hook/build.dart new file mode 100644 index 000000000..476a9ad51 --- /dev/null +++ b/native/native_core/hook/build.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:code_assets/code_assets.dart'; +import 'package:hooks/hooks.dart'; +import 'package:native_toolchain_rust/native_toolchain_rust.dart'; + +const _crate = '../crates/native_core_ffi'; + +// Cargo's dep-info only lists source files, so these have to rerun the hook too. +const _manifests = [ + '../.cargo/config.toml', + '../Cargo.toml', + '../Cargo.lock', + '../crates/native_core/Cargo.toml', + '$_crate/Cargo.toml', + '$_crate/rust-toolchain.toml', +]; + +void main(List args) async { + await build(args, (input, output) async { + if (!input.config.buildCodeAssets) return; + final code = input.config.code; + output.dependencies.addAll(_manifests.map(input.packageRoot.resolve)); + final target = _crossTarget(code); + if (target != null) { + await _rustup(['target', 'add', '--toolchain', await _channel(input), target]); + } + await RustBuilder(assetName: 'src/bindings.g.dart', cratePath: _crate).run(input: input, output: output); + }); +} + +// mise sets RUSTUP_TOOLCHAIN, which makes rustup ignore rust-toolchain.toml and never install +// its targets, so add the one this build needs. +String? _crossTarget(CodeConfig code) => switch ((code.targetOS, code.targetArchitecture)) { + (OS.android, Architecture.arm) => 'armv7-linux-androideabi', + (OS.android, Architecture.arm64) => 'aarch64-linux-android', + (OS.android, Architecture.x64) => 'x86_64-linux-android', + (OS.iOS, Architecture.arm64) when code.iOS.targetSdk == IOSSdk.iPhoneSimulator => 'aarch64-apple-ios-sim', + (OS.iOS, Architecture.arm64) => 'aarch64-apple-ios', + (OS.iOS, Architecture.x64) => 'x86_64-apple-ios', + _ => null, +}; + +Future _channel(BuildInput input) async { + final toml = await File.fromUri(input.packageRoot.resolve('$_crate/rust-toolchain.toml')).readAsString(); + final match = RegExp(r'^channel = "([^"]+)"', multiLine: true).firstMatch(toml); + if (match == null) throw StateError('channel missing in rust-toolchain.toml'); + return match.group(1)!; +} + +Future _rustup(List args) async { + final home = Platform.environment[Platform.isWindows ? 'USERPROFILE' : 'HOME']; + ProcessResult result; + try { + result = await Process.run('rustup', args); + } on ProcessException { + if (home == null) rethrow; + result = await Process.run('$home/.cargo/bin/rustup', args); + } + if (result.exitCode != 0) { + throw ProcessException('rustup', args, '${result.stderr}', result.exitCode); + } +} diff --git a/native/native_core/lib/native_core.dart b/native/native_core/lib/native_core.dart new file mode 100644 index 000000000..5dd8fb5ea --- /dev/null +++ b/native/native_core/lib/native_core.dart @@ -0,0 +1,12 @@ +import 'package:ffi/ffi.dart'; + +import 'src/bindings.g.dart'; + +String version() { + final ptr = native_core_version(); + try { + return ptr.cast().toDartString(); + } finally { + native_core_free_string(ptr); + } +} diff --git a/native/native_core/pubspec.yaml b/native/native_core/pubspec.yaml new file mode 100644 index 000000000..edfad839e --- /dev/null +++ b/native/native_core/pubspec.yaml @@ -0,0 +1,19 @@ +name: native_core +publish_to: none + +environment: + sdk: '>=3.13.0 <4.0.0' + +dependencies: + code_assets: ^1.2.1 + ffi: ^2.2.0 + flutter: + sdk: flutter + hooks: ^2.2.0 + native_toolchain_rust: ^1.0.6 + +dev_dependencies: + ffigen: ^21.0.0 + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/native/native_core/test/native_core_test.dart b/native/native_core/test/native_core_test.dart new file mode 100644 index 000000000..5d59c3194 --- /dev/null +++ b/native/native_core/test/native_core_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:native_core/native_core.dart'; + +void main() { + test('loads the core and returns its version', () { + expect(version(), isNotEmpty); + }); +}