Compare commits

...

22 Commits

Author SHA1 Message Date
Santo Shakil
3da42ca655 ci: run only fmt, clippy, and tests for the native core 2026-07-20 21:08:58 +06:00
Santo Shakil
8b22917228 ci: add native mise lockfile so the native job resolves tools 2026-07-20 20:42:16 +06:00
Santo Shakil
2786cc16a1 fix(mobile): target minSdk for the native lib, move memory-card thumbhash to the core, add native CI 2026-07-20 20:11:39 +06:00
Santo Shakil
ce718e6c10 Merge remote-tracking branch 'origin/main' into feat/native-core 2026-07-20 15:33:46 +06:00
Santo Shakil
9b15a1cfa8 Merge remote-tracking branch 'origin/main' into feat/native-core
# Conflicts:
#	CODEOWNERS
2026-07-11 14:40:29 +06:00
Santo Shakil
ae284a6c30 refactor(mobile): consolidate thumbhash into the rust core and clean up the ffi layer 2026-07-11 14:39:42 +06:00
Santo Shakil
110c8e1a0f feat(mobile): log native failures to logcat and the unified log 2026-07-11 04:57:13 +06:00
Santo Shakil
13a050fc67 feat(mobile): decode thumbhash in the native core 2026-07-11 04:56:46 +06:00
Santo Shakil
ef359c02f1 refactor(mobile): replace the android C layer with the rust core 2026-07-11 04:56:06 +06:00
Santo Shakil
f899d33120 Merge remote-tracking branch 'origin/main' into feat/native-core 2026-07-09 16:33:39 +06:00
Santo Shakil
f53477e474 refactor: drop hashing, the core ships the two pixel ports 2026-07-07 00:58:46 +06:00
Santo Shakil
b2b5841472 feat: add the 10 bit to 8888 convert to the core 2026-07-07 00:34:39 +06:00
Santo Shakil
c0f9c50abe Merge remote-tracking branch 'origin/main' into feat/native-core 2026-07-07 00:34:39 +06:00
Santo Shakil
7d27eeceb6 refactor: rename ffi crate to immich_core_ffi 2026-07-05 01:55:32 +06:00
Santo Shakil
bca556b6c5 fix: trust mise configs and ensure rust target in the build hook 2026-07-04 18:52:31 +06:00
Santo Shakil
ead25d62b5 chore: show raw xcodebuild output for pr builds 2026-07-04 18:26:12 +06:00
Santo Shakil
8734066187 fix: record rust tool options in mise lockfile 2026-07-04 17:40:23 +06:00
Santo Shakil
d2580e9d53 fix: preinstall rust cross targets via mise 2026-07-04 17:05:03 +06:00
Santo Shakil
07043c4af1 fix: add rust to mobile mise lockfile 2026-07-04 16:37:24 +06:00
Santo Shakil
fec11ab156 fix: provide rustup via mise for mobile builds 2026-07-04 16:29:14 +06:00
Santo Shakil
3919887c2a fix: include host targets in rust toolchain file 2026-07-04 16:06:36 +06:00
Santo Shakil
bb8e242fbe feat: add native rust core 2026-06-29 20:53:47 +06:00
70 changed files with 3933 additions and 694 deletions

View File

@@ -65,6 +65,7 @@ jobs:
filters: |
mobile:
- 'mobile/**'
- 'native/**'
force-filters: |
- '.github/workflows/build-mobile.yml'
force-events: 'workflow_call,workflow_dispatch'
@@ -154,6 +155,59 @@ jobs:
flutter build apk --release
fi
- name: Verify native Android compatibility
run: |
apk=mobile/build/app/outputs/flutter-apk/app-release.apk
sdk=${ANDROID_SDK_ROOT:-${ANDROID_HOME:?Android SDK path is not set}}
min_sdk=$(sed -nE 's/^[[:space:]]*minSdk[[:space:]]*=[[:space:]]*([0-9]+)[[:space:]]*$/\1/p' mobile/android/app/build.gradle)
[[ $min_sdk =~ ^[0-9]+$ ]] || { printf 'Could not parse minSdk from mobile/android/app/build.gradle\n' >&2; exit 1; }
readelf=$(find "$sdk/ndk" -path '*/toolchains/llvm/prebuilt/*/bin/llvm-readelf' -print | sort -V | tail -n 1)
[[ -n $readelf ]] || { printf 'No llvm-readelf found under %s\n' "$sdk/ndk" >&2; exit 1; }
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
test -f "$apk"
[[ -x $readelf ]] || { printf 'llvm-readelf is not executable: %s\n' "$readelf" >&2; exit 1; }
for abi in armeabi-v7a arm64-v8a x86_64; do
so="$dir/$abi.so"
unzip -p "$apk" "lib/$abi/libimmich_core_ffi.so" > "$so"
test -s "$so"
notes=$("$readelf" -n "$so")
headers=$("$readelf" -lW "$so")
printf '%s notes:\n%s\n' "$abi" "$notes"
printf '%s LOAD headers:\n%s\n' "$abi" "$(printf '%s\n' "$headers" | awk '/^[[:space:]]*LOAD[[:space:]]/')"
bytes=$(printf '%s\n' "$notes" | awk '
/^[[:space:]]*Android[[:space:]]/ { android = 1; next }
android && /description data:/ {
sub(/^.*description data:[[:space:]]*/, "")
print $1, $2, $3, $4
exit
}
')
read -r b0 b1 b2 b3 <<< "$bytes"
for byte in "$b0" "$b1" "$b2" "$b3"; do
[[ $byte =~ ^[0-9a-fA-F]{2}$ ]]
done
api=$((16#$b0 + (16#$b1 << 8) + (16#$b2 << 16) + (16#$b3 << 24)))
printf '%s Android API: %d\n' "$abi" "$api"
if ((api > min_sdk)); then
printf '%s Android API %d exceeds minSdk %d\n' "$abi" "$api" "$min_sdk" >&2
exit 1
fi
alignments=$(printf '%s\n' "$headers" | awk '/^[[:space:]]*LOAD[[:space:]]/ { print $NF }')
test -n "$alignments"
while read -r alignment; do
if [[ $alignment != 0x4000 ]]; then
printf '%s LOAD alignment %s is not 0x4000\n' "$abi" "$alignment" >&2
exit 1
fi
done <<< "$alignments"
done
- name: Publish Android Artifact
id: upload-apk
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

View File

@@ -35,6 +35,7 @@ jobs:
mobile:
- 'mobile/**'
- 'i18n/en.json'
- 'native/**'
force-filters: |
- '.github/workflows/static_analysis.yml'
force-events: 'workflow_dispatch,release'

View File

@@ -59,6 +59,10 @@ jobs:
- 'mise.toml'
mobile:
- 'mobile/**'
- 'native/**'
- 'mise.toml'
native:
- 'native/**'
- 'mise.toml'
machine-learning:
- 'machine-learning/**'
@@ -69,6 +73,45 @@ jobs:
- '.github/workflows/test.yml'
force-events: 'workflow_dispatch'
native-tests:
name: Test & Lint Native Core
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).native == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./native
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@1af396ae134e4bc3b63d947e672bc68bf4ff9dc5 # create-workflow-token-action-v3.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@3bca63ca3c15020293b36b51737a3ee2c773340b # use-mise-action-v3.1.0
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./native
- name: Check formatting
run: cargo fmt --all --check
- name: Run Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Run tests
run: cargo test --workspace --locked
script-unit-tests:
name: Scripts unit tests
needs: pre-job

View File

@@ -5,3 +5,4 @@
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil @agg23
/native/ @santoshakil @mertalev

View File

@@ -12,6 +12,7 @@ config_roots = [
"docs",
".github",
"machine-learning",
"native",
]
[tools]

View File

@@ -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)

View File

@@ -77,11 +77,6 @@ android {
}
namespace 'app.alextran.immich'
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
flutter {

View File

@@ -1,52 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <string.h>
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;
}

View File

@@ -1,173 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <stdint.h>
#include <android/bitmap.h>
// 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;
}

View File

@@ -6,7 +6,7 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024
object NativeBuffer {
init {
System.loadLibrary("native_buffer")
System.loadLibrary("immich_core_ffi")
}
@JvmStatic
@@ -21,9 +21,6 @@ object NativeBuffer {
@JvmStatic
external fun wrap(address: Long, capacity: Int): ByteBuffer
@JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
@JvmStatic
external fun createGlobalRef(obj: Any): Long
}
@@ -35,8 +32,12 @@ class NativeByteBuffer(initialCapacity: Int) {
inline fun ensureHeadroom() {
if (offset == capacity) {
capacity *= 2
pointer = NativeBuffer.realloc(pointer, capacity)
check(capacity <= Int.MAX_VALUE / 2) { "Native buffer capacity overflow" }
val newCapacity = capacity * 2
val newPointer = NativeBuffer.realloc(pointer, newCapacity)
check(newPointer != 0L) { "Native buffer realloc failed" }
pointer = newPointer
capacity = newCapacity
}
}

View File

@@ -4,26 +4,27 @@ import android.graphics.Bitmap
object NativeImage {
init {
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
System.loadLibrary("native_buffer")
System.loadLibrary("immich_core_ffi")
}
/**
* Rotates an RGBA_8888 [bitmap] to the given EXIF [orientation], writing the result into a freshly
* malloc'd native buffer. Returns the buffer address (free it with [NativeBuffer.free]) and fills
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap can't be handled (e.g. a
* non-8888 config) so the caller can fall back.
* Rotates an RGBA_8888 [bitmap] and returns a malloc'd buffer, or 0 on failure.
* [outInfo] receives width, height, and row bytes.
*/
@JvmStatic
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
/**
* Converts an RGBA_1010102 [bitmap] (what a 10-bit HEIC/AVIF decodes to on API 33+) to RGBA_8888,
* writing the result into a freshly malloc'd native buffer in one pass, with no intermediate
* ARGB_8888 bitmap. Returns the buffer address (free it with [NativeBuffer.free]) and fills
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap isn't RGBA_1010102 so the
* caller can fall back to a Skia copy.
* Converts an RGBA_1010102 [bitmap] to RGBA_8888 and returns a malloc'd buffer, or 0 on failure.
* [outInfo] receives width, height, and row bytes.
*/
@JvmStatic
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
/**
* Decodes a ThumbHash into a malloc'd RGBA_8888 buffer, or 0 on failure.
* [outInfo] receives width, height, and row bytes.
*/
@JvmStatic
external fun thumbhash(hash: ByteArray, outInfo: IntArray): Long
}

View File

@@ -13,6 +13,7 @@ import android.provider.MediaStore.Video
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeImage
import kotlin.math.*
@@ -49,17 +50,23 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
// Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles
// colors when copied verbatim. Convert those straight into the output buffer in native code -
// one pass, no intermediate ARGB_8888 bitmap.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
val source1010102 =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102
if (source1010102) {
val info = IntArray(3)
val pointer = NativeImage.convert1010102(this, info)
if (pointer != 0L) {
recycle()
return mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
return buildMap {
put("pointer", pointer)
put("width", info[0].toLong())
put("height", info[1].toLong())
put("rowBytes", info[2].toLong())
if (BuildConfig.DEBUG) {
put("source1010102", 1L)
put("converted1010102", 1L)
}
}
}
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
}
@@ -70,12 +77,16 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
try {
val buffer = NativeBuffer.wrap(pointer, size)
bitmap.copyPixelsToBuffer(buffer)
return mapOf(
"pointer" to pointer,
"width" to bitmap.width.toLong(),
"height" to bitmap.height.toLong(),
"rowBytes" to (bitmap.width * 4).toLong()
)
return buildMap {
put("pointer", pointer)
put("width", bitmap.width.toLong())
put("height", bitmap.height.toLong())
put("rowBytes", (bitmap.width * 4).toLong())
if (BuildConfig.DEBUG) {
put("source1010102", if (source1010102) 1L else 0L)
put("converted1010102", 0L)
}
}
} catch (e: Throwable) {
NativeBuffer.free(pointer)
throw e
@@ -101,12 +112,14 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
threadPool.execute {
try {
val bytes = Base64.getDecoder().decode(thumbhash)
val image = ThumbHash.thumbHashToRGBA(bytes)
val info = IntArray(3)
val pointer = NativeImage.thumbhash(bytes, info)
require(pointer != 0L) { "Invalid thumbhash" }
val res = mapOf(
"pointer" to image.pointer,
"width" to image.width.toLong(),
"height" to image.height.toLong(),
"rowBytes" to (image.width * 4).toLong()
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
callback(Result.success(res))
} catch (e: Exception) {

View File

@@ -1,166 +0,0 @@
package app.alextran.immich.images;
// 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.
import java.nio.ByteBuffer;
import app.alextran.immich.NativeBuffer;
// modified to use native allocations
public final class ThumbHash {
/**
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The width, height, and pixels of the rendered placeholder image.
*/
public static Image thumbHashToRGBA(byte[] hash) {
// Read the constants
int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
float l_dc = (float) (header24 & 63) / 63.0f;
float p_dc = (float) ((header24 >> 6) & 63) / 31.5f - 1.0f;
float q_dc = (float) ((header24 >> 12) & 63) / 31.5f - 1.0f;
float l_scale = (float) ((header24 >> 18) & 31) / 31.0f;
boolean hasAlpha = (header24 >> 23) != 0;
float p_scale = (float) ((header16 >> 3) & 63) / 63.0f;
float q_scale = (float) ((header16 >> 9) & 63) / 63.0f;
boolean isLandscape = (header16 >> 15) != 0;
int lx = Math.max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
int ly = Math.max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
float a_dc = hasAlpha ? (float) (hash[5] & 15) / 15.0f : 1.0f;
float a_scale = (float) ((hash[5] >> 4) & 15) / 15.0f;
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
int ac_start = hasAlpha ? 6 : 5;
int ac_index = 0;
Channel l_channel = new Channel(lx, ly);
Channel p_channel = new Channel(3, 3);
Channel q_channel = new Channel(3, 3);
Channel a_channel = null;
ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale);
ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f);
ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f);
if (hasAlpha) {
a_channel = new Channel(5, 5);
a_channel.decode(hash, ac_start, ac_index, a_scale);
}
float[] l_ac = l_channel.ac;
float[] p_ac = p_channel.ac;
float[] q_ac = q_channel.ac;
float[] a_ac = hasAlpha ? a_channel.ac : null;
// Decode using the DCT into RGB
float ratio = thumbHashToApproximateAspectRatio(hash);
int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
int size = w * h * 4;
long pointer = NativeBuffer.allocate(size);
ByteBuffer rgba = NativeBuffer.wrap(pointer, size);
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
float[] fx = new float[cx_stop];
float[] fy = new float[cy_stop];
for (int y = 0, i = 0; y < h; y++) {
for (int x = 0; x < w; x++, i += 4) {
float l = l_dc, p = p_dc, q = q_dc, a = a_dc;
// Precompute the coefficients
for (int cx = 0; cx < cx_stop; cx++)
fx[cx] = (float) Math.cos(Math.PI / w * (x + 0.5f) * cx);
for (int cy = 0; cy < cy_stop; cy++)
fy[cy] = (float) Math.cos(Math.PI / h * (y + 0.5f) * cy);
// Decode L
for (int cy = 0, j = 0; cy < ly; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++)
l += l_ac[j] * fx[cx] * fy2;
}
// Decode P and Q
for (int cy = 0, j = 0; cy < 3; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; cx++, j++) {
float f = fx[cx] * fy2;
p += p_ac[j] * f;
q += q_ac[j] * f;
}
}
// Decode A
if (hasAlpha)
for (int cy = 0, j = 0; cy < 5; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; cx++, j++)
a += a_ac[j] * fx[cx] * fy2;
}
// Convert to RGB
float b = l - 2.0f / 3.0f * p;
float r = (3.0f * l - b + q) / 2.0f;
float g = r - q;
rgba.put(i, (byte) Math.max(0, Math.round(255.0f * Math.min(1, r))));
rgba.put(i + 1, (byte) Math.max(0, Math.round(255.0f * Math.min(1, g))));
rgba.put(i + 2, (byte) Math.max(0, Math.round(255.0f * Math.min(1, b))));
rgba.put(i + 3, (byte) Math.max(0, Math.round(255.0f * Math.min(1, a))));
}
}
return new Image(w, h, pointer);
}
/**
* Extracts the approximate aspect ratio of the original image.
*
* @param hash The bytes of the ThumbHash.
* @return The approximate aspect ratio (i.e. width / height).
*/
public static float thumbHashToApproximateAspectRatio(byte[] hash) {
byte header = hash[3];
boolean hasAlpha = (hash[2] & 0x80) != 0;
boolean isLandscape = (hash[4] & 0x80) != 0;
int lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
int ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
return (float) lx / (float) ly;
}
public static final class Image {
public int width;
public int height;
public long pointer;
public Image(int width, int height, long pointer) {
this.width = width;
this.height = height;
this.pointer = pointer;
}
}
private static final class Channel {
int nx;
int ny;
float[] ac;
Channel(int nx, int ny) {
this.nx = nx;
this.ny = ny;
int n = 0;
for (int cy = 0; cy < ny; cy++)
for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
n++;
ac = new float[n];
}
int decode(byte[] hash, int start, int index, float scale) {
for (int i = 0; i < ac.length; i++) {
int data = hash[start + (index >> 1)] >> ((index & 1) << 2);
ac[i] = ((float) (data & 15) / 7.5f - 1.0f) * scale;
index++;
}
return index;
}
}
}

View File

@@ -0,0 +1,97 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_native_core/immich_native_core.dart';
import 'package:integration_test/integration_test.dart';
Uint8List _px1010102(int r, int g, int b, int a) {
final px = (r & 0x3FF) | ((g & 0x3FF) << 10) | ((b & 0x3FF) << 20) | ((a & 0x3) << 30);
return Uint8List(4)..buffer.asByteData().setUint32(0, px, Endian.little);
}
Uint8List? _withBuffers(
Uint8List src,
int dstLen,
bool Function(Pointer<Uint8> src, int dstLen, Pointer<Uint8> dst) call,
) {
final srcPtr = calloc<Uint8>(src.length);
final dstPtr = calloc<Uint8>(dstLen);
try {
srcPtr.asTypedList(src.length).setAll(0, src);
if (!call(srcPtr, dstLen, dstPtr)) {
return null;
}
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
} finally {
calloc.free(srcPtr);
calloc.free(dstPtr);
}
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
test('loads the native core', () {
final ptr = immich_core_version();
expect(ptr, isNot(equals(nullptr)));
final version = ptr.cast<Utf8>().toDartString();
immich_core_free_string(ptr);
expect(version, isNotEmpty);
});
test('reports swapped orientations', () {
for (final o in [5, 6, 7, 8]) {
expect(immich_core_orientation_swaps_dims(o), isTrue, reason: 'o=$o');
}
for (final o in [0, 1, 2, 3, 4, 9]) {
expect(immich_core_orientation_swaps_dims(o), isFalse, reason: 'o=$o');
}
});
test('rotates RGBA pixels', () {
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
final out = _withBuffers(src, 8, (s, len, d) => immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 3, d, len));
expect(out, [0, 255, 0, 255, 255, 0, 0, 255]);
});
test('converts RGBA_1010102 pixels', () {
// 179 and 111 distinguish rounded scaling from `>> 2`.
final src = Uint8List.fromList([..._px1010102(1023, 0, 0, 3), ..._px1010102(179, 111, 0, 3)]);
final out = _withBuffers(
src,
8,
(s, len, d) => immich_core_rgba1010102_to_rgba8888(s, src.length, 8, 2, 1, d, len),
);
expect(out, isNotNull);
expect(out!.sublist(0, 4), [255, 0, 0, 255]);
expect(out.sublist(4, 8), [45, 28, 0, 255]);
});
test('decodes a thumbhash', () {
final hash = base64Decode('1QcSHQRnh493V4dIh4eXh1h4kJUI');
final hashPtr = malloc<Uint8>(hash.length);
final info = malloc<Uint32>(3);
try {
hashPtr.asTypedList(hash.length).setAll(0, hash);
final ptr = immich_core_thumbhash_decode(hashPtr, hash.length, info);
expect(ptr, isNot(equals(nullptr)));
expect((info[0], info[1], info[2]), (23, 32, 23 * 4));
final len = info[0] * info[1] * 4;
final pixels = ptr.asTypedList(len);
for (var i = 3; i < len; i += 4) {
expect(pixels[i], 255, reason: 'alpha at $i');
}
expect(pixels.toSet().length, greaterThan(2));
malloc.free(ptr);
expect(immich_core_thumbhash_decode(hashPtr, 4, info), equals(nullptr));
} finally {
malloc.free(hashPtr);
malloc.free(info);
}
});
}

View File

@@ -0,0 +1,194 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:typed_data';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:integration_test/integration_test.dart';
import 'package:photo_manager/photo_manager.dart';
const _fixture10BitAvifB64 =
'AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAAD5bWV0YQAAAAAAAAAvaGRscgAAAAAA'
'AAAAcGljdAAAAAAAAAAAAAAAAFBpY3R1cmVIYW5kbGVyAAAAAA5waXRtAAAAAAABAAAAHmlsb2MA'
'AAAARAAAAQABAAAAAQAAASEAAAAmAAAAKGlpbmYAAAAAAAEAAAAaaW5mZQIAAAAAAQAAYXYwMUNv'
'bG9yAAAAAGppcHJwAAAAS2lwY28AAAAUaXNwZQAAAAAAAABAAAAAQAAAABBwaXhpAAAAAAMKCgoA'
'AAAMYXYxQ4EATAAAAAATY29scm5jbHgAAQACAAEAAAAAF2lwbWEAAAAAAAAAAQABBAECgwQAAAAu'
'bWRhdAoNAAAAAq//jV86AgQCCDIVEACLggAAAAAAgAAifC/LKY1kV6Bd';
// 16x12 linear DNG with EXIF orientation 6; the pixel strip is appended below.
const _fixtureOrientedDngHeaderB64 =
'SUkqAAgAAAAVAAABAwABAAAAEAAAAAEBAwABAAAADAAAAAIBAwADAAAACgEAAAMBAwABAAAAAQAAAAYBAwABAAAATIgAAAoB'
'AwABAAAAAQAAABEBBAABAAAAvAEAABIBAwABAAAABgAAABUBAwABAAAAAwAAABYBAwABAAAADAAAABcBBAABAAAAgAQAABwB'
'AwABAAAAAQAAACkBAwACAAAAAAABAD4BBQACAAAAEAEAAD8BBQAGAAAAIAEAABLGAQAEAAAAAQQAABPGAQAEAAAAAQEAABTG'
'AgAMAAAAUAEAACHGCgAJAAAAXAEAACjGBQADAAAApAEAAFrGAwABAAAAFQAAAAAAAAAQABAAEAA3GqAAAAAAAiuHCgAAACAA'
'hetRAAAAgADD9agAAAAAAs3MTAAAAAABzcxMAAAAgADNzEwAAAAAAo/C9QAAAAAQSW1taWNoIFRlc3QAAQAAAAEAAAAAAAAA'
'AQAAAAAAAAABAAAAAAAAAAEAAAABAAAAAQAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAA'
'AQAAAAEAAAABAAAA';
Uint8List _orientedDng() {
final header = base64Decode(_fixtureOrientedDngHeaderB64);
final pixels = ByteData(16 * 12 * 6);
for (var y = 0; y < 12; y++) {
final r = (11 - y) * 65535 ~/ 11;
final b = y * 65535 ~/ 11;
for (var x = 0; x < 16; x++) {
final offset = (y * 16 + x) * 6;
pixels.setUint16(offset, r, Endian.little);
pixels.setUint16(offset + 2, 0, Endian.little);
pixels.setUint16(offset + 4, b, Endian.little);
}
}
return Uint8List(header.length + pixels.lengthInBytes)
..setAll(0, header)
..setAll(header.length, pixels.buffer.asUint8List());
}
Uint8List _read(int address, int length) => Uint8List.fromList(Pointer<Uint8>.fromAddress(address).asTypedList(length));
void _free(int address) => malloc.free(Pointer<Uint8>.fromAddress(address));
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
if (!Platform.isAndroid) {
return;
}
final api = LocalImageApi();
final fixture = base64Decode(_fixture10BitAvifB64);
String? assetId;
String? orientedAssetId;
setUpAll(() async {
await PhotoManager.setIgnorePermissionCheck(true);
final entity = await PhotoManager.editor.saveImage(fixture, filename: 'immich_jni_fixture.avif');
assetId = entity.id;
});
tearDownAll(() async {
final ids = [assetId, orientedAssetId].whereType<String>().toList();
if (ids.isNotEmpty) {
try {
await PhotoManager.editor.deleteWithIds(ids);
} catch (_) {}
}
});
test('thumbhash JNI roundtrip', () async {
final a = await api.getThumbhash('1QcSHQRnh493V4dIh4eXh1h4kJUI');
final b = await api.getThumbhash('1QcSHQRnh493V4dIh4eXh1h4kJUI');
final (w, h, rowBytes) = (a['width']!, a['height']!, a['rowBytes']!);
expect(a['pointer'], isNot(0));
expect(w, inInclusiveRange(1, 128));
expect(h, inInclusiveRange(1, 128));
expect(rowBytes, w * 4);
final pixelsA = _read(a['pointer']!, rowBytes * h);
final pixelsB = _read(b['pointer']!, rowBytes * h);
_free(a['pointer']!);
_free(b['pointer']!);
expect(pixelsA, pixelsB);
expect(pixelsA.toSet().length, greaterThan(1));
});
test('encoded image buffer roundtrip', () async {
final res = await api.requestImage(
assetId!,
requestId: 900001,
width: 0,
height: 0,
isVideo: false,
preferEncoded: true,
);
expect(res, isNotNull);
expect(res!['length'], fixture.length);
final bytes = _read(res['pointer']!, res['length']!);
_free(res['pointer']!);
expect(bytes, fixture);
});
test('10-bit decode runs NativeImage.convert1010102', () async {
final Map<String, int>? res;
try {
res = await api.requestImage(
assetId!,
requestId: 900002,
width: 0,
height: 0,
isVideo: false,
preferEncoded: false,
);
} on PlatformException catch (e) {
markTestSkipped('device cannot decode the 10-bit AVIF fixture: ${e.message}');
return;
}
expect(res, isNotNull);
final (w, h, rowBytes) = (res!['width']!, res['height']!, res['rowBytes']!);
expect(w, 64);
expect(h, 64);
expect(rowBytes, w * 4);
final pixels = _read(res['pointer']!, rowBytes * h);
_free(res['pointer']!);
final source1010102 = res['source1010102'];
expect(source1010102, isNotNull, reason: 'decode result did not report its source format');
if (source1010102 == 0) {
markTestSkipped('device decoded the 10-bit AVIF fixture without RGBA_1010102');
return;
}
expect(source1010102, 1);
expect(
res['converted1010102'],
1,
reason: 'RGBA_1010102 source fell back instead of running the native conversion',
);
for (final (x, y) in [(2, 2), (32, 32), (61, 61)]) {
final o = (y * w + x) * 4;
expect(pixels[o], closeTo(45, 12), reason: 'R at ($x,$y)');
expect(pixels[o + 1], closeTo(139, 12), reason: 'G at ($x,$y)');
expect(pixels[o + 2], closeTo(107, 12), reason: 'B at ($x,$y)');
expect(pixels[o + 3], 255, reason: 'A at ($x,$y)');
}
});
test('raw EXIF orientation rotates through NativeImage.rotate', () async {
final sdkInt = (await DeviceInfoPlugin().androidInfo).version.sdkInt;
if (sdkInt < 29) {
markTestSkipped('raw image rotation needs Android 10 or newer');
return;
}
final entity = await PhotoManager.editor.saveImage(_orientedDng(), filename: 'immich_jni_orientation.dng');
orientedAssetId = entity.id;
expect(await entity.mimeTypeAsync, anyOf('image/dng', 'image/x-adobe-dng'));
expect(entity.orientation, 90);
expect((entity.width, entity.height), (16, 12));
final Map<String, int>? res;
try {
res = await api.requestImage(
entity.id,
requestId: 900003,
width: 0,
height: 0,
isVideo: false,
preferEncoded: false,
);
} on PlatformException catch (e) {
markTestSkipped('device cannot decode the DNG fixture: ${e.message}');
return;
}
expect(res, isNotNull);
final (w, h, rowBytes) = (res!['width']!, res['height']!, res['rowBytes']!);
expect((w, h, rowBytes), (12, 16, 48));
final pixels = _read(res['pointer']!, rowBytes * h);
_free(res['pointer']!);
for (final (x, r, b) in [(0, 0, 255), (11, 255, 0)]) {
final o = 8 * rowBytes + x * 4;
expect(pixels[o], closeTo(r, 12), reason: 'R at ($x,8)');
expect(pixels[o + 2], closeTo(b, 12), reason: 'B at ($x,8)');
}
});
}

View File

@@ -36,7 +36,6 @@
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; };
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; };
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */; };
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
@@ -131,7 +130,6 @@
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = "<group>"; };
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -357,7 +355,6 @@
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */,
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
);
path = Images;
sourceTree = "<group>";
@@ -628,7 +625,6 @@
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,

View File

@@ -0,0 +1,57 @@
import Foundation
import OSLog
// Native assets embed the framework without linking Runner, so resolve its symbol at runtime.
enum NativeCore {
typealias ThumbhashDecode = @convention(c) (
UnsafePointer<UInt8>?, UInt, UnsafeMutablePointer<UInt32>?
) -> UnsafeMutablePointer<UInt8>?
static let thumbhashDecode: ThumbhashDecode? = symbol("immich_core_thumbhash_decode")
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "app.alextran.immich",
category: "NativeCore"
)
private static let handle: UnsafeMutableRawPointer? = load()
private static func load() -> UnsafeMutableRawPointer? {
if let frameworks = Bundle.main.privateFrameworksPath {
let path = "\(frameworks)/immich_core_ffi.framework/immich_core_ffi"
dlerror()
if let handle = dlopen(path, RTLD_NOW) {
return handle
}
let error = lastError()
logger.warning("dlopen failed for \(path, privacy: .public): \(error, privacy: .public)")
}
dlerror()
guard let handle = dlopen(nil, RTLD_NOW) else {
let error = lastError()
logger.error("dlopen failed for process scope: \(error, privacy: .public)")
return nil
}
return handle
}
private static func symbol<T>(_ name: String) -> T? {
guard let handle else {
logger.error("native core is unavailable while loading \(name, privacy: .public)")
return nil
}
dlerror()
guard let sym = dlsym(handle, name) else {
let error = lastError()
logger.error("dlsym failed for \(name, privacy: .public): \(error, privacy: .public)")
return nil
}
return unsafeBitCast(sym, to: T.self)
}
private static func lastError() -> String {
guard let error = dlerror() else { return "unknown error" }
return String(cString: error)
}
}

View File

@@ -38,15 +38,26 @@ class LocalImageApiImpl: LocalImageApi {
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.addOperation {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
guard let data = Data(base64Encoded: thumbhash) else {
return completion(.failure(PigeonError(code: "invalid-base64", message: "Invalid base64 thumbhash", details: nil)))
}
guard let decode = NativeCore.thumbhashDecode else {
return completion(.failure(PigeonError(code: "native-core-unavailable", message: "Native thumbhash decoder is unavailable", details: nil)))
}
var info = [UInt32](repeating: 0, count: 3)
let pointer = data.withUnsafeBytes { bytes in
decode(bytes.bindMemory(to: UInt8.self).baseAddress, UInt(bytes.count), &info)
}
guard let pointer else {
return completion(.failure(PigeonError(code: "invalid-thumbhash", message: "Invalid thumbhash", details: nil)))
}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
"width": Int64(width),
"height": Int64(height),
"rowBytes": Int64(width * 4)
"pointer": Int64(Int(bitPattern: pointer)),
"width": Int64(info[0]),
"height": Int64(info[1]),
"rowBytes": Int64(info[2])
]))
}
}

View File

@@ -1,225 +0,0 @@
// 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.
import Foundation
// NOTE: Swift has an exponential-time type checker and compiling very simple
// expressions can easily take many seconds, especially when expressions involve
// numeric type constructors.
//
// This file deliberately breaks compound expressions up into separate variables
// to improve compile time even though this comes at the expense of readability.
// This is a known workaround for this deficiency in the Swift compiler.
//
// The following command is helpful when debugging Swift compile time issues:
//
// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies
//
// These optimizations brought the compile time for this file from around 2.5
// seconds to around 250ms (10x faster).
// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is
// really awful. Debug builds compile a very generic indexing iterator thing
// that makes many nested calls for every iteration, which makes debug-build
// performance crawl.
//
// This file deliberately avoids for-in loops that loop for more than a few
// times to improve debug-build run time even though this comes at the expense
// of readability. Similarly unsafe pointers are used instead of array getters
// to avoid unnecessary bounds checks, which have extra overhead in debug builds.
//
// These optimizations brought the run time to encode and decode 10 ThumbHashes
// in debug mode from 700ms to 70ms (10x faster).
// changed signature and allocation method to avoid automatic GC
func thumbHashToRGBA(hash: Data) -> (Int, Int, UnsafeMutableRawBufferPointer) {
// Read the constants
let h0 = UInt32(hash[0])
let h1 = UInt32(hash[1])
let h2 = UInt32(hash[2])
let h3 = UInt16(hash[3])
let h4 = UInt16(hash[4])
let header24 = h0 | (h1 << 8) | (h2 << 16)
let header16 = h3 | (h4 << 8)
let il_dc = header24 & 63
let ip_dc = (header24 >> 6) & 63
let iq_dc = (header24 >> 12) & 63
var l_dc = Float32(il_dc)
var p_dc = Float32(ip_dc)
var q_dc = Float32(iq_dc)
l_dc = l_dc / 63
p_dc = p_dc / 31.5 - 1
q_dc = q_dc / 31.5 - 1
let il_scale = (header24 >> 18) & 31
var l_scale = Float32(il_scale)
l_scale = l_scale / 31
let hasAlpha = (header24 >> 23) != 0
let ip_scale = (header16 >> 3) & 63
let iq_scale = (header16 >> 9) & 63
var p_scale = Float32(ip_scale)
var q_scale = Float32(iq_scale)
p_scale = p_scale / 63
q_scale = q_scale / 63
let isLandscape = (header16 >> 15) != 0
let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
let lx = Int(lx16)
let ly = Int(ly16)
var a_dc = Float32(1)
var a_scale = Float32(1)
if hasAlpha {
let ia_dc = hash[5] & 15
let ia_scale = hash[5] >> 4
a_dc = Float32(ia_dc)
a_scale = Float32(ia_scale)
a_dc /= 15
a_scale /= 15
}
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
let ac_start = hasAlpha ? 6 : 5
var ac_index = 0
let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in
var ac: [Float32] = []
for cy in 0 ..< ny {
var cx = cy > 0 ? 0 : 1
while cx * ny < nx * (ny - cy) {
let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15;
var fac = Float32(iac)
fac = (fac / 7.5 - 1) * scale
ac.append(fac)
ac_index += 1
cx += 1
}
}
return ac
}
let l_ac = decodeChannel(lx, ly, l_scale)
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : []
// Decode using the DCT into RGB
let ratio = thumbHashToApproximateAspectRatio(hash: hash)
let fw = round(ratio > 1 ? 32 : 32 * ratio)
let fh = round(ratio > 1 ? 32 / ratio : 32)
let w = Int(fw)
let h = Int(fh)
let pointer = UnsafeMutableRawBufferPointer.allocate(
byteCount: w * h * 4,
alignment: MemoryLayout<UInt8>.alignment
)
var rgba = pointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
let cx_stop = max(lx, hasAlpha ? 5 : 3)
let cy_stop = max(ly, hasAlpha ? 5 : 3)
var fx = [Float32](repeating: 0, count: cx_stop)
var fy = [Float32](repeating: 0, count: cy_stop)
fx.withUnsafeMutableBytes { fx in
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
fy.withUnsafeMutableBytes { fy in
let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count)
var y = 0
while y < h {
var x = 0
while x < w {
var l = l_dc
var p = p_dc
var q = q_dc
var a = a_dc
// Precompute the coefficients
var cx = 0
while cx < cx_stop {
let fw = Float32(w)
let fxx = Float32(x)
let fcx = Float32(cx)
fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx)
cx += 1
}
var cy = 0
while cy < cy_stop {
let fh = Float32(h)
let fyy = Float32(y)
let fcy = Float32(cy)
fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy)
cy += 1
}
// Decode L
var j = 0
cy = 0
while cy < ly {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx * ly < lx * (ly - cy) {
l += l_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
// Decode P and Q
j = 0
cy = 0
while cy < 3 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 3 - cy {
let f = fx[cx] * fy2
p += p_ac[j] * f
q += q_ac[j] * f
j += 1
cx += 1
}
cy += 1
}
// Decode A
if hasAlpha {
j = 0
cy = 0
while cy < 5 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 5 - cy {
a += a_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
}
// Convert to RGB
var b = l - 2 / 3 * p
var r = (3 * l - b + q) / 2
var g = r - q
r = max(0, 255 * min(1, r))
g = max(0, 255 * min(1, g))
b = max(0, 255 * min(1, b))
a = max(0, 255 * min(1, a))
rgba[0] = UInt8(r)
rgba[1] = UInt8(g)
rgba[2] = UInt8(b)
rgba[3] = UInt8(a)
rgba = rgba.advanced(by: 4)
x += 1
}
y += 1
}
}
}
return (w, h, pointer)
}
func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 {
let header = hash[3]
let hasAlpha = (hash[2] & 0x80) != 0
let isLandscape = (hash[4] & 0x80) != 0
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
return Float32(lx) / Float32(ly)
}

View File

@@ -34,6 +34,13 @@ platform :ios do
)
end
# Xcode build phases need mise trust saved to disk.
def trust_mise_configs
return unless system("command -v mise > /dev/null 2>&1")
sh("mise trust ../../../mise.toml")
sh("mise trust ../../mise.toml")
end
# Helper method to assemble xcargs with optional CUSTOM_GROUP_ID override
def build_xcargs(group_id: nil)
args = "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual"
@@ -102,6 +109,8 @@ end
)
app_identifier = base_bundle_id
trust_mise_configs
# Set version number if provided
if version_number
increment_version_number(version_number: version_number)
@@ -258,6 +267,8 @@ end
api_key = get_api_key
trust_mise_configs
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
@@ -284,6 +295,7 @@ end
configuration: "Release",
export_method: "app-store",
skip_package_ipa: true,
xcodebuild_formatter: "", # raw xcodebuild output so script-phase errors show in CI logs
xcargs: build_xcargs(group_id: DEV_GROUP_ID),
export_options: {
provisioningProfiles: {

View File

@@ -1,16 +1,76 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:thumbhash/thumbhash.dart' as thumbhash;
import 'package:immich_native_core/immich_native_core.dart';
ObjectRef<Uint8List?> useDriftBlurHashRef(RemoteAsset? asset) {
if (asset?.thumbHash == null) {
return useRef(null);
return useRef(decodeDriftThumbHash(asset?.thumbHash));
}
Uint8List? decodeDriftThumbHash(String? thumbHash) {
if (thumbHash == null || thumbHash.isEmpty) {
return null;
}
final rbga = thumbhash.thumbHashToRGBA(base64Decode(asset!.thumbHash!));
final Uint8List hash;
try {
hash = base64Decode(thumbHash);
} on FormatException {
return null;
}
return useRef(thumbhash.rgbaToBmp(rbga));
final hashPtr = malloc<Uint8>(hash.length);
final info = malloc<Uint32>(3);
try {
hashPtr.asTypedList(hash.length).setAll(0, hash);
final rgba = immich_core_thumbhash_decode(hashPtr, hash.length, info);
if (rgba == nullptr) {
return null;
}
try {
return _rgbaToBmp(rgba, info[0], info[1], info[2]);
} finally {
malloc.free(rgba);
}
} finally {
malloc.free(hashPtr);
malloc.free(info);
}
}
Uint8List? _rgbaToBmp(Pointer<Uint8> rgba, int width, int height, int stride) {
if (width <= 0 || width > 32 || height <= 0 || height > 32 || stride != width * 4) {
return null;
}
const headerSize = 54;
final imageSize = stride * height;
final data = ByteData(headerSize + imageSize);
data
..setUint16(0, 0x4d42, Endian.little)
..setUint32(2, data.lengthInBytes, Endian.little)
..setUint32(10, headerSize, Endian.little)
..setUint32(14, 40, Endian.little)
..setInt32(18, width, Endian.little)
..setInt32(22, -height, Endian.little)
..setUint16(26, 1, Endian.little)
..setUint16(28, 32, Endian.little)
..setUint32(34, imageSize, Endian.little);
final pixels = rgba.asTypedList(imageSize);
for (var src = 0, dst = headerSize; src < imageSize; src += 4, dst += 4) {
data
..setUint8(dst, pixels[src + 2])
..setUint8(dst + 1, pixels[src + 1])
..setUint8(dst + 2, pixels[src])
..setUint8(dst + 3, pixels[src + 3]);
}
return data.buffer.asUint8List();
}

View File

@@ -184,3 +184,10 @@ url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c55847602
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.rust]]
version = "1.92.0"
backend = "core:rust"
[tools.rust.options]
targets = "aarch64-apple-ios,aarch64-apple-ios-sim,aarch64-linux-android,armv7-linux-androideabi,x86_64-apple-ios,x86_64-linux-android"

View File

@@ -1,6 +1,8 @@
[tools]
"aqua:flutter/flutter" = "3.44.6"
java = "21.0.2"
# RUSTUP_TOOLCHAIN makes build hooks ignore rust-toolchain.toml targets.
rust = { version = "1.92.0", targets = "armv7-linux-androideabi,aarch64-linux-android,x86_64-linux-android,aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios" }
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"

View File

@@ -912,6 +912,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.2"
immich_native_core:
dependency: "direct main"
description:
path: "../native/immich_native_core"
relative: true
source: path
version: "0.1.0"
immich_ui:
dependency: "direct main"
description:
@@ -1124,6 +1131,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.19.1"
native_toolchain_rust:
dependency: transitive
description:
name: native_toolchain_rust
sha256: faa57d2258a3b0fd2a634054f54e4496c9fcbd971977e7d2b7e6916d56892857
url: "https://pub.dev"
source: hosted
version: "1.0.4+0"
native_video_player:
dependency: "direct main"
description:
@@ -1740,14 +1755,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
thumbhash:
dependency: "direct main"
description:
name: thumbhash
sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37"
url: "https://pub.dev"
source: hosted
version: "0.1.0+1"
timezone:
dependency: "direct main"
description:
@@ -1756,6 +1763,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.4"
toml:
dependency: transitive
description:
name: toml
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
url: "https://pub.dev"
source: hosted
version: "0.18.0"
typed_data:
dependency: transitive
description:

View File

@@ -39,6 +39,8 @@ dependencies:
hooks_riverpod: ^2.6.1
http: ^1.6.0
image_picker: ^1.2.1
immich_native_core:
path: ../native/immich_native_core
immich_ui:
path: './packages/ui'
intl: ^0.20.2
@@ -70,7 +72,6 @@ dependencies:
sqlite3: ^3.3.2
sqlite_async: 0.14.2
sqlite3_connection_pool: ^0.2.6
thumbhash: 0.1.0+1
timezone: ^0.9.4
url_launcher: ^6.3.2
uuid: ^4.5.3

View File

@@ -0,0 +1,56 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
RemoteAsset _asset(String thumbHash) => RemoteAsset(
id: '1',
name: 'test.jpg',
ownerId: '1',
checksum: 'checksum',
type: AssetType.image,
createdAt: DateTime(2024),
updatedAt: DateTime(2024),
thumbHash: thumbHash,
isEdited: false,
);
void main() {
test('decodes the native RGBA output into a BMP', () async {
final bmp = decodeDriftThumbHash('1QcSHQRnh493V4dIh4eXh1h4kJUI');
expect(bmp, isNotNull);
final data = ByteData.sublistView(bmp!);
expect(data.getUint16(0, Endian.little), 0x4d42);
expect(data.getUint32(2, Endian.little), bmp.length);
expect(data.getInt32(18, Endian.little), 23);
expect(data.getInt32(22, Endian.little), -32);
expect(data.getUint16(28, Endian.little), 32);
final codec = await ui.instantiateImageCodec(bmp);
final frame = await codec.getNextFrame();
expect((frame.image.width, frame.image.height), (23, 32));
frame.image.dispose();
codec.dispose();
});
testWidgets('bad hashes fall back without throwing during build', (tester) async {
for (final hash in ['not base64!', 'AQIDBA==']) {
await tester.pumpWidget(
MaterialApp(
home: HookBuilder(
key: UniqueKey(),
builder: (context) => Text(useDriftBlurHashRef(_asset(hash)).value == null ? 'fallback' : 'decoded'),
),
),
);
expect(find.text('fallback'), findsOneWidget);
expect(tester.takeException(), isNull);
}
});
}

2
native/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
smoke/*.node

693
native/Cargo.lock generated Normal file
View File

@@ -0,0 +1,693 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[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",
"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.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "ctor"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb"
[[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.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[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 = "immich_core"
version = "0.1.0"
[[package]]
name = "immich_core_ffi"
version = "0.1.0"
dependencies = [
"cbindgen",
"immich_core",
"jni",
"libc",
"tokio",
]
[[package]]
name = "immich_core_napi"
version = "0.1.0"
dependencies = [
"immich_core",
"napi",
"napi-build",
"napi-derive",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
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",
]
[[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",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[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.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "napi"
version = "3.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c71997d6f7ad4a756966e452426848ac27d3b37a295302d63afbbcce0270f93"
dependencies = [
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash",
]
[[package]]
name = "napi-build"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ba572deef53e2c386759a8c2014175a62679d74ff83adc205c8bc0e0285727"
dependencies = [
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "5.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd961eb2aa8965e3f29722d754f3a86907eb1984e2fbcbe3fe87b9a02d6bfba"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
dependencies = [
"libloading",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
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 = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[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.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
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.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
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 = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
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.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"pin-project-lite",
]
[[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.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[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.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

34
native/Cargo.toml Normal file
View File

@@ -0,0 +1,34 @@
[workspace]
resolver = "2"
members = [
"crates/immich_core",
"crates/immich_core_ffi",
"crates/immich_core_napi",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "AGPL-3.0-only"
[workspace.dependencies]
tokio = { version = "1", default-features = false, features = [
"rt-multi-thread",
"time",
] }
libc = { version = "0.2", default-features = false }
jni = { version = "0.22", default-features = false }
napi = { version = "3", default-features = false }
napi-derive = "3"
napi-build = "2"
cbindgen = { version = "0.29", default-features = false }
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
# FFI panic guards require the default unwind strategy.
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true

29
native/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Native core
Shared Rust code used by the Immich mobile app. Flutter builds it from source
through the `immich_native_core` native-assets hook.
## Layout
```
crates/
immich_core shared image and thumbhash code
immich_core_ffi C ABI and Android JNI exports
immich_core_napi Node binding
immich_native_core/ Flutter package and build hook
smoke/ host Dart and Node checks
```
## Commands
Building the mobile app requires `rustup`. The toolchain and targets are pinned
in `crates/immich_core_ffi/rust-toolchain.toml`.
```
mise run build
mise run test
mise run lint
mise run fmt
mise run codegen
mise run test:flutter
mise run smoke
```

View File

@@ -0,0 +1,13 @@
[package]
name = "immich_core"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["image", "thumbhash"]
image = []
thumbhash = []
[lints]
workspace = true

View File

@@ -0,0 +1,310 @@
//! RGBA8888 EXIF rotation and RGBA_1010102 conversion.
// androidx ExifInterface orientation values.
const FLIP_HORIZONTAL: i32 = 2;
const ROTATE_180: i32 = 3;
const FLIP_VERTICAL: i32 = 4;
const TRANSPOSE: i32 = 5;
const ROTATE_90: i32 = 6;
const TRANSVERSE: i32 = 7;
const ROTATE_270: i32 = 8;
// Keep transpose writes inside a 4 KB tile.
const TILE: usize = 32;
pub fn swaps_dims(orientation: i32) -> bool {
matches!(orientation, ROTATE_90 | ROTATE_270 | TRANSPOSE | TRANSVERSE)
}
// src(sx, sy) maps to base + sx*step_x + sy*step_y in dst.
fn affine_for(o: i32, sw: i64, sh: i64, dw: i64) -> (i64, i64, i64) {
match o {
ROTATE_90 => (sh - 1, dw, -1),
ROTATE_270 => ((sw - 1) * dw, -dw, 1),
ROTATE_180 => ((sh - 1) * dw + (sw - 1), -1, -dw),
FLIP_HORIZONTAL => (sw - 1, -1, dw),
FLIP_VERTICAL => ((sh - 1) * dw, 1, -dw),
TRANSPOSE => (0, dw, 1),
TRANSVERSE => ((sw - 1) * dw + (sh - 1), -dw, -1),
_ => (0, 1, dw),
}
}
/// Rotates RGBA8888 pixels for an EXIF orientation.
/// Returns false when the dimensions or buffers are invalid.
pub fn rotate_rgba8888(
src: &[u8],
src_stride: usize,
sw: usize,
sh: usize,
orientation: i32,
dst: &mut [u8],
) -> bool {
let Some(src_row_len) = sw.checked_mul(4) else {
return false;
};
if sw == 0 || sh == 0 || src_stride < src_row_len {
return false;
}
let dw = if swaps_dims(orientation) { sh } else { sw };
let dh = if swaps_dims(orientation) { sw } else { sh };
let Some(src_len) = src_stride.checked_mul(sh) else {
return false;
};
let Some(dst_len) = dw.checked_mul(dh).and_then(|len| len.checked_mul(4)) else {
return false;
};
if src.len() < src_len || dst.len() < dst_len {
return false;
}
let (base, step_x, step_y) = affine_for(orientation, sw as i64, sh as i64, dw as i64);
for ty in (0..sh).step_by(TILE) {
let y_end = (ty + TILE).min(sh);
for tx in (0..sw).step_by(TILE) {
let x_end = (tx + TILE).min(sw);
for sy in ty..y_end {
let row = sy * src_stride;
let mut idx = base + sy as i64 * step_y + tx as i64 * step_x;
for sx in tx..x_end {
let s = row + sx * 4;
let d = idx as usize * 4;
dst[d..d + 4].copy_from_slice(&src[s..s + 4]);
idx += step_x;
}
}
}
}
true
}
#[inline]
fn scale10(v: u32) -> u32 {
(v * 16336 + 32768) >> 16
}
/// Converts little-endian RGBA_1010102 pixels to RGBA8888.
/// Returns false when the dimensions or buffers are invalid.
pub fn rgba1010102_to_rgba8888(
src: &[u8],
src_stride: usize,
w: usize,
h: usize,
dst: &mut [u8],
) -> bool {
let Some(row_len) = w.checked_mul(4) else {
return false;
};
if w == 0 || h == 0 || src_stride < row_len {
return false;
}
let Some(src_len) = src_stride.checked_mul(h) else {
return false;
};
let Some(dst_len) = w.checked_mul(h).and_then(|len| len.checked_mul(4)) else {
return false;
};
if src.len() < src_len || dst.len() < dst_len {
return false;
}
// Plain arithmetic auto-vectorizes to NEON and measured faster than a LUT on-device (#29631).
for y in 0..h {
let s_row = y * src_stride;
let d_row = y * w * 4;
for x in 0..w {
let s = s_row + x * 4;
// Avoid unaligned u32 reads.
let px = u32::from_le_bytes([src[s], src[s + 1], src[s + 2], src[s + 3]]);
let d = d_row + x * 4;
let r = scale10(px & 0x3FF);
let g = scale10((px >> 10) & 0x3FF);
let b = scale10((px >> 20) & 0x3FF);
let a = ((px >> 30) & 0x3) * 85;
let rgba = r | (g << 8) | (b << 16) | (a << 24);
dst[d..d + 4].copy_from_slice(&rgba.to_le_bytes());
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
// Independent EXIF mapping for the affine tests.
fn ref_dst_xy(o: i32, sx: usize, sy: usize, sw: usize, sh: usize) -> (usize, usize) {
match o {
FLIP_HORIZONTAL => (sw - 1 - sx, sy),
ROTATE_180 => (sw - 1 - sx, sh - 1 - sy),
FLIP_VERTICAL => (sx, sh - 1 - sy),
TRANSPOSE => (sy, sx),
ROTATE_90 => (sh - 1 - sy, sx),
TRANSVERSE => (sh - 1 - sy, sw - 1 - sx),
ROTATE_270 => (sy, sw - 1 - sx),
_ => (sx, sy),
}
}
fn pixel(i: usize) -> [u8; 4] {
[
(i & 0xff) as u8,
((i >> 8) & 0xff) as u8,
((i >> 16) & 0xff) as u8,
0xff,
]
}
fn check(o: i32, sw: usize, sh: usize) {
let mut src = vec![0u8; sw * sh * 4];
for sy in 0..sh {
for sx in 0..sw {
let i = sy * sw + sx;
src[i * 4..i * 4 + 4].copy_from_slice(&pixel(i));
}
}
let (dw, dh) = if swaps_dims(o) { (sh, sw) } else { (sw, sh) };
let mut dst = vec![0u8; dw * dh * 4];
assert!(rotate_rgba8888(&src, sw * 4, sw, sh, o, &mut dst));
for sy in 0..sh {
for sx in 0..sw {
let (dx, dy) = ref_dst_xy(o, sx, sy, sw, sh);
let di = dy * dw + dx;
let si = sy * sw + sx;
assert_eq!(&dst[di * 4..di * 4 + 4], &pixel(si), "o={o} src({sx},{sy})");
}
}
}
#[test]
fn all_orientations_match_exif_reference() {
for o in [1, 2, 3, 4, 5, 6, 7, 8] {
check(o, 4, 3);
check(o, 1, 5);
check(o, 5, 1);
check(o, 40, 33); // spans multiple tiles
}
}
#[test]
fn swaps_dims_only_for_the_90_270_transpose_family() {
for o in [TRANSPOSE, ROTATE_90, TRANSVERSE, ROTATE_270] {
assert!(swaps_dims(o), "o={o}");
}
for o in [0, 1, FLIP_HORIZONTAL, ROTATE_180, FLIP_VERTICAL, 9, -1] {
assert!(!swaps_dims(o), "o={o}");
}
}
#[test]
fn identity_for_normal_orientation() {
let src: Vec<u8> = (0..24u8).collect();
let mut dst = vec![0u8; 24];
assert!(rotate_rgba8888(&src, 8, 2, 3, 1, &mut dst));
assert_eq!(src, dst);
}
#[test]
fn respects_src_stride_padding() {
let (sw, sh, stride) = (2usize, 2usize, 12usize);
let mut src = vec![0u8; stride * sh];
for sy in 0..sh {
for sx in 0..sw {
let i = sy * sw + sx;
src[sy * stride + sx * 4..sy * stride + sx * 4 + 4].copy_from_slice(&pixel(i));
}
}
let mut dst = vec![0u8; sw * sh * 4];
assert!(rotate_rgba8888(&src, stride, sw, sh, ROTATE_180, &mut dst));
for i in 0..4 {
assert_eq!(&dst[i * 4..i * 4 + 4], &pixel(3 - i));
}
}
#[test]
fn rejects_bad_sizes() {
let src = vec![0u8; 16];
let mut small = vec![0u8; 4];
assert!(!rotate_rgba8888(&src, 8, 2, 2, ROTATE_90, &mut small));
assert!(!rotate_rgba8888(&src, 4, 2, 2, 1, &mut small));
assert!(!rotate_rgba8888(
&src,
usize::MAX,
usize::MAX,
1,
1,
&mut small
));
assert!(!rotate_rgba8888(&src, usize::MAX, 1, 2, 1, &mut small));
}
#[test]
fn scale10_matches_exact_mapping() {
for v in 0..1024 {
assert_eq!(scale10(v), (v * 255 + 511) / 1023, "v={v}");
}
}
// 179 and 111 distinguish rounded scaling from `>> 2`.
#[test]
fn scale10_matches_skia() {
for (v, want) in [
(0, 0),
(111, 28),
(179, 45),
(230, 57),
(304, 76),
(1023, 255),
] {
assert_eq!(scale10(v), want, "v={v}");
}
for (a, want) in [0, 85, 170, 255].into_iter().enumerate() {
assert_eq!(a as u32 * 85, want);
}
}
#[test]
fn convert_packing() {
let red = 0x0000_03FFu32.to_le_bytes();
let alpha = 0xC000_0000u32.to_le_bytes();
let mut src = Vec::new();
src.extend_from_slice(&red);
src.extend_from_slice(&alpha);
let mut dst = vec![0u8; 8];
assert!(rgba1010102_to_rgba8888(&src, 8, 2, 1, &mut dst));
assert_eq!(&dst[0..4], &[255, 0, 0, 0]);
assert_eq!(&dst[4..8], &[0, 0, 0, 255]);
}
#[test]
fn convert_respects_src_stride_padding() {
let (w, h, stride) = (2usize, 2usize, 12usize);
let px = |v: u32| (v | 0xC000_0000).to_le_bytes();
let mut src = vec![0u8; stride * h];
for (i, v) in [0u32, 179, 111, 1023].iter().enumerate() {
let (x, y) = (i % w, i / w);
src[y * stride + x * 4..y * stride + x * 4 + 4].copy_from_slice(&px(*v));
}
let mut dst = vec![0u8; w * h * 4];
assert!(rgba1010102_to_rgba8888(&src, stride, w, h, &mut dst));
for (i, want) in [0u8, 45, 28, 255].iter().enumerate() {
assert_eq!(dst[i * 4], *want, "R of pixel {i}");
assert_eq!(dst[i * 4 + 3], 255, "A of pixel {i}");
}
}
#[test]
fn convert_rejects_bad_sizes() {
let src = vec![0u8; 16];
let mut small = vec![0u8; 4];
assert!(!rgba1010102_to_rgba8888(&src, 0, 0, 0, &mut small));
assert!(!rgba1010102_to_rgba8888(&src, 4, 2, 2, &mut small));
assert!(!rgba1010102_to_rgba8888(&src, 8, 2, 2, &mut small));
assert!(!rgba1010102_to_rgba8888(
&src,
usize::MAX,
usize::MAX,
1,
&mut small,
));
assert!(!rgba1010102_to_rgba8888(&src, usize::MAX, 1, 2, &mut small));
}
}

View File

@@ -0,0 +1,21 @@
//! Shared native logic for Immich.
#[cfg(feature = "image")]
pub mod image;
#[cfg(feature = "thumbhash")]
pub mod thumbhash;
pub fn core_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_is_present() {
assert!(!core_version().is_empty());
}
}

View File

@@ -0,0 +1,481 @@
//! ThumbHash placeholder decoding.
//
// Ported from Evan Wallace's reference implementation:
// 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.
struct Header {
l_dc: f32,
p_dc: f32,
q_dc: f32,
l_scale: f32,
p_scale: f32,
q_scale: f32,
a_dc: f32,
a_scale: f32,
has_alpha: bool,
lx: usize,
ly: usize,
ac_start: usize,
w: usize,
h: usize,
}
fn ac_len(nx: usize, ny: usize) -> usize {
let mut n = 0;
for cy in 0..ny {
let mut cx = if cy > 0 { 0 } else { 1 };
while cx * ny < nx * (ny - cy) {
n += 1;
cx += 1;
}
}
n
}
fn parse(hash: &[u8]) -> Option<Header> {
if hash.len() < 5 {
return None;
}
let header24 = (hash[0] as u32) | ((hash[1] as u32) << 8) | ((hash[2] as u32) << 16);
let header16 = (hash[3] as u32) | ((hash[4] as u32) << 8);
let has_alpha = (header24 >> 23) != 0;
if has_alpha && hash.len() < 6 {
return None;
}
let is_landscape = (header16 >> 15) != 0;
let lx_raw = if is_landscape {
if has_alpha {
5
} else {
7
}
} else {
(header16 & 7) as usize
};
let ly_raw = if is_landscape {
(header16 & 7) as usize
} else if has_alpha {
5
} else {
7
};
// The format computes size before clamping the component counts.
if lx_raw == 0 || ly_raw == 0 {
return None;
}
let ratio = lx_raw as f32 / ly_raw as f32;
let w = (if ratio > 1.0 { 32.0 } else { 32.0 * ratio }).round() as usize;
let h = (if ratio > 1.0 { 32.0 / ratio } else { 32.0 }).round() as usize;
let lx = lx_raw.max(3);
let ly = ly_raw.max(3);
let ac_start = if has_alpha { 6 } else { 5 };
let mut nibbles = ac_len(lx, ly) + 2 * ac_len(3, 3);
if has_alpha {
nibbles += ac_len(5, 5);
}
if hash.len() < ac_start + nibbles.div_ceil(2) {
return None;
}
let (a_dc, a_scale) = if has_alpha {
((hash[5] & 15) as f32 / 15.0, (hash[5] >> 4) as f32 / 15.0)
} else {
(1.0, 1.0)
};
Some(Header {
l_dc: (header24 & 63) as f32 / 63.0,
p_dc: ((header24 >> 6) & 63) as f32 / 31.5 - 1.0,
q_dc: ((header24 >> 12) & 63) as f32 / 31.5 - 1.0,
l_scale: ((header24 >> 18) & 31) as f32 / 31.0,
p_scale: ((header16 >> 3) & 63) as f32 / 63.0,
q_scale: ((header16 >> 9) & 63) as f32 / 63.0,
a_dc,
a_scale,
has_alpha,
lx,
ly,
ac_start,
w,
h,
})
}
fn decode_channel(
hash: &[u8],
ac_start: usize,
ac_index: &mut usize,
nx: usize,
ny: usize,
scale: f32,
) -> Vec<f32> {
let mut ac = Vec::with_capacity(ac_len(nx, ny));
for cy in 0..ny {
let mut cx = if cy > 0 { 0 } else { 1 };
while cx * ny < nx * (ny - cy) {
let byte = hash[ac_start + (*ac_index >> 1)];
let nibble = (byte >> ((*ac_index & 1) << 2)) & 15;
ac.push((nibble as f32 / 7.5 - 1.0) * scale);
*ac_index += 1;
cx += 1;
}
}
ac
}
/// Returns the decoded size, or None for a malformed hash.
pub fn dims(hash: &[u8]) -> Option<(u32, u32)> {
parse(hash).map(|hdr| (hdr.w as u32, hdr.h as u32))
}
/// Decodes a hash into a caller-owned RGBA8888 buffer.
pub fn to_rgba(hash: &[u8], dst: &mut [u8]) -> bool {
let Some(hdr) = parse(hash) else {
return false;
};
if dst.len() < hdr.w * hdr.h * 4 {
return false;
}
let mut ac_index = 0;
let l_ac = decode_channel(
hash,
hdr.ac_start,
&mut ac_index,
hdr.lx,
hdr.ly,
hdr.l_scale,
);
let p_ac = decode_channel(hash, hdr.ac_start, &mut ac_index, 3, 3, hdr.p_scale * 1.25);
let q_ac = decode_channel(hash, hdr.ac_start, &mut ac_index, 3, 3, hdr.q_scale * 1.25);
let a_ac = if hdr.has_alpha {
decode_channel(hash, hdr.ac_start, &mut ac_index, 5, 5, hdr.a_scale)
} else {
Vec::new()
};
let cx_stop = hdr.lx.max(if hdr.has_alpha { 5 } else { 3 });
let cy_stop = hdr.ly.max(if hdr.has_alpha { 5 } else { 3 });
let mut fx = vec![0.0f32; cx_stop];
let mut fy = vec![0.0f32; cy_stop];
let mut i = 0;
for y in 0..hdr.h {
for x in 0..hdr.w {
let mut l = hdr.l_dc;
let mut p = hdr.p_dc;
let mut q = hdr.q_dc;
let mut a = hdr.a_dc;
for (cx, f) in fx.iter_mut().enumerate() {
*f = (std::f64::consts::PI / hdr.w as f64 * (x as f64 + 0.5) * cx as f64).cos()
as f32;
}
for (cy, f) in fy.iter_mut().enumerate() {
*f = (std::f64::consts::PI / hdr.h as f64 * (y as f64 + 0.5) * cy as f64).cos()
as f32;
}
let mut j = 0;
for (cy, fyv) in fy.iter().enumerate().take(hdr.ly) {
let fy2 = fyv * 2.0;
let mut cx = if cy > 0 { 0 } else { 1 };
while cx * hdr.ly < hdr.lx * (hdr.ly - cy) {
l += l_ac[j] * fx[cx] * fy2;
j += 1;
cx += 1;
}
}
j = 0;
for (cy, fyv) in fy.iter().enumerate().take(3) {
let fy2 = fyv * 2.0;
let start = if cy > 0 { 0 } else { 1 };
for fxv in &fx[start..3 - cy] {
let f = fxv * fy2;
p += p_ac[j] * f;
q += q_ac[j] * f;
j += 1;
}
}
if hdr.has_alpha {
j = 0;
for (cy, fyv) in fy.iter().enumerate().take(5) {
let fy2 = fyv * 2.0;
let start = if cy > 0 { 0 } else { 1 };
for fxv in &fx[start..5 - cy] {
a += a_ac[j] * fxv * fy2;
j += 1;
}
}
}
let b = l - 2.0 / 3.0 * p;
let r = (3.0 * l - b + q) / 2.0;
let g = r - q;
dst[i] = to_u8(r);
dst[i + 1] = to_u8(g);
dst[i + 2] = to_u8(b);
dst[i + 3] = to_u8(a);
i += 4;
}
}
true
}
fn to_u8(v: f32) -> u8 {
(255.0 * v.clamp(0.0, 1.0)).round() as u8
}
#[cfg(test)]
mod tests {
use super::*;
// Captured from the old Swift decoder; channel values may differ by one.
const OPAQUE_B64: &str = "1QcSHQRnh493V4dIh4eXh1h4kJUI";
const ALPHA_B64: &str = "1QeSHQR6Z4ePd1eHSIeHl4dYeJCVCIQ8WuEpd7M=";
const OPAQUE_RGBA_HEX: &str = "404d71ff424f72ff475375ff4d5878ff545d7cff5b6480ff626984ff686e87ff6d7389ff71758bff72778bff72778bff\
70768aff6d7589ff6a7388ff667287ff627087ff5e6f87ff5b6e88ff586d89ff566d8aff556d8aff546d8bff414d70ff\
434f71ff475374ff4d5877ff545d7bff5b637fff626983ff696e86ff6d7288ff717589ff727689ff717689ff707588ff\
6d7487ff697286ff657085ff616e85ff5d6d85ff5a6c86ff576b86ff566b87ff546b88ff546b89ff434e6fff455070ff\
495373ff4f5876ff565e7aff5d637eff646981ff6a6e84ff6e7286ff717487ff727587ff727586ff707485ff6c7283ff\
687082ff646e81ff606c81ff5c6b81ff596a82ff576983ff556984ff546985ff536985ff47506fff495270ff4d5573ff\
535a76ff5a5f7aff61657dff686b81ff6d6f83ff727385ff747586ff757685ff747584ff717382ff6e7180ff696f7fff\
656c7eff616a7dff5d697dff5a687eff57687fff566880ff556881ff546882ff4e5571ff505772ff555a74ff5a5f78ff\
61647bff686a7fff6f6f82ff747485ff787786ff7b7986ff7b7986ff7a7884ff777682ff727380ff6e707eff696e7cff\
646c7cff616a7cff5e697dff5b697eff5a697fff596a80ff596a81ff595c75ff5b5e76ff5f6279ff65667cff6c6c80ff\
737283ff7a7787ff7f7b89ff837f8bff85808bff86808aff847f88ff807d85ff7c7983ff767680ff71737eff6d717dff\
696f7dff666e7eff646e80ff626e81ff626f82ff616f83ff67677cff69697dff6d6c7fff737183ff7a7787ff817d8bff\
88828eff8e8791ff928a92ff948c93ff948c92ff928a8fff8e878cff898389ff848086ff7e7c84ff797983ff757883ff\
727783ff707785ff6f7887ff6e7888ff6e7989ff767384ff797585ff7d7888ff837e8bff8a848fff928a94ff999098ff\
a0959bffa4999dffa69a9dffa69a9cffa4989affa09596ff9a9192ff948c8fff8e888cff89858bff84838bff81838bff\
7f838dff7e838fff7e8490ff7e8591ff857e8bff88808dff8d848fff938a93ff9b9098ffa3979dffab9ea2ffb2a3a5ff\
b7a7a8ffb9a9a8ffb9a9a7ffb7a7a5ffb2a4a1ffac9f9dffa59a99ff9f9696ff999294ff959093ff918f94ff908f95ff\
8f9097ff8e9199ff8e919aff928790ff958a92ff998e95ffa09499ffa99b9effb1a2a4ffbaa9a9ffc1afadffc7b4b0ff\
cab7b1ffcab7b1ffc8b5aeffc3b1aaffbcaba5ffb5a6a1ffaea19dffa89d9bffa39a9aff9f999aff9d999cff9d9a9eff\
9c9ba0ff9c9ca1ff9a8c91ff9c8e92ffa29396ffa9999affb2a0a0ffbba8a6ffc4b0acffccb7b1ffd2bcb5ffd6bfb6ff\
d6bfb6ffd4bdb3ffcfb9afffc8b3aaffc0ada5ffb8a8a0ffb1a39effaca09cffa89f9dffa69f9effa5a0a0ffa5a0a2ff\
a5a1a3ff9b8a8cff9e8d8dffa39191ffab9896ffb4a09cffbea8a3ffc8b1a9ffd0b8afffd7beb3ffdbc1b5ffdbc2b5ff\
d9bfb2ffd3bbadffccb5a8ffc4aea2ffbba89dffb4a39affae9f98ffaa9e98ffa89d9affa79e9bffa79f9dffa7a09eff\
958381ff988583ff9e8a86ffa5908bffaf9892ffb9a199ffc3aa9fffccb2a6ffd3b8aaffd8bcacffd8bcacffd6baaaff\
d0b5a5ffc8af9fffbfa799ffb6a093ffae9b8fffa8978dffa4958dffa2958effa19590ffa19692ffa19793ff8a7571ff\
8c7873ff927c76ff99827bffa38a81ffad9389ffb79c90ffc1a596ffc8ab9bffcdaf9dffcdaf9dffcbad9bffc5a896ff\
bca18fffb39989ffaa9283ffa18c7eff9b877cff96857bff94857cff93857eff938780ff938781ff79645eff7c665fff\
816a63ff887067ff91786dff9b8174ffa68a7bffaf9282ffb79886ffbb9c89ffbc9d89ffb99a86ffb39581ffaa8e7bff\
a18674ff977e6dff8e7768ff877366ff837165ff817066ff807169ff80726bff80736cff675149ff69534bff6d564eff\
745c52ff7d6357ff866b5eff907464ff997c6bffa1826fffa58672ffa68772ffa3846fff9d7f6aff947763ff8a6f5cff\
806756ff786051ff715c4eff6d5a4eff6b5a4fff6a5b52ff6a5c54ff6b5d55ff533e37ff554038ff59433aff5f483dff\
674e42ff705548ff795d4eff826554ff896b58ff8e6f5bff8f705bff8c6d58ff866853ff7d614dff735846ff69503fff\
614a3bff5a4638ff574438ff55443aff55463dff554740ff564841ff422e27ff432f27ff473129ff4c352cff523b2fff\
5a4134ff634839ff6b4f3fff725543ff775946ff785a46ff755844ff70533fff674c39ff5e4432ff543d2cff4c3728ff\
473326ff433227ff42332aff42342dff433730ff443832ff33211bff34221bff36231cff3a261eff402a20ff473024ff\
4f3629ff573d2dff5d4232ff624634ff634735ff614633ff5c412fff553b2aff4c3424ff432d1fff3c281bff37251aff\
34251cff34261fff352923ff362b26ff372d28ff271814ff281814ff291914ff2c1b14ff311e16ff372218ff3e281cff\
452e21ff4c3325ff503728ff533929ff513828ff4d3525ff462f20ff3e291bff372317ff301f15ff2c1d15ff2b1d17ff\
2b201bff2d2320ff2f2624ff302826ff1e1211ff1e1210ff1f120fff21130fff24150fff291811ff301d14ff372318ff\
3d281cff432d20ff452f22ff452f22ff422d1fff3c281cff352318ff2f1e15ff291b13ff261a14ff251b18ff271e1dff\
292222ff2c2626ff2d2829ff170f11ff170e10ff170e0eff180d0dff1a0e0cff1f110dff241510ff2b1b13ff322018ff\
38251cff3b291fff3c2a1fff3a281eff35251cff2f2119ff2a1d17ff251b17ff231b19ff231d1dff252122ff282528ff\
2b292dff2d2b30ff110d13ff110c11ff100b0fff110a0dff120a0bff160c0cff1b100eff221512ff291b16ff2f211bff\
33251eff352720ff342620ff31241fff2c211dff271e1cff231d1cff221d1fff232024ff25242aff282930ff2c2d35ff\
2d3038ff0c0d16ff0c0b14ff0b0a11ff0a080eff0b070cff0e090cff130c0eff1a1212ff211817ff281e1cff2d2320ff\
302623ff302624ff2d2524ff292222ff252022ff221f23ff202026ff21232bff242731ff272c37ff2b313dff2d3340ff\
080c19ff070b17ff050814ff040610ff04050eff07060dff0c090fff130f13ff1a1618ff221c1eff282223ff2b2527ff\
2c2728ff2a2628ff262328ff222127ff1f2028ff1d212bff1e2430ff212836ff242d3cff273142ff293445ff030c1cff\
020a1aff000717ff000513ff000310ff00040fff050710ff0c0d14ff14141aff1d1b21ff232126ff27252aff28272cff\
26262dff22242cff1e212bff1a202cff18202eff192232ff1b2638ff1e2b3eff212f43ff223146ff000c20ff000a1dff\
000719ff000415ff000212ff000211ff000512ff070b16ff0f121dff181a23ff1e2129ff23252eff232630ff212530ff\
1d232fff18202dff141d2dff111d2fff111e32ff122137ff14253cff172941ff182b43ff000b23ff000920ff00061cff\
000317ff000114ff000113ff000414ff020a19ff0b121fff131926ff1a202cff1f2430ff1f2632ff1d2432ff182130ff\
121d2eff0d1a2dff09182dff071830ff081a33ff091d38ff0b203cff0c223eff000b25ff000923ff00061eff00021aff\
000016ff000015ff000316ff00091bff071121ff101928ff171f2eff1b2433ff1b2534ff182333ff131e31ff0c192dff\
05152bff00122aff00112bff00122eff001532ff001735ff001837ff000c28ff000925ff000621ff00021cff000018ff\
000016ff000318ff00091cff051123ff0d192aff141f30ff182334ff182435ff142134ff0e1c30ff06162cff001129ff\
000d27ff000b27ff000b29ff000c2cff000e2fff000f30ff000c29ff000a27ff000622ff00021dff000019ff000018ff\
000319ff00091eff031124ff0c182bff121f31ff162235ff162336ff112034ff0a1a30ff02132bff000d27ff000824ff\
000523ff000525ff000627ff000729ff00082bff000c2aff000a28ff000623ff00021eff00001aff000019ff00031aff\
00091fff021125ff0b182cff111f32ff152236ff142236ff101f34ff081930ff00122aff000b26ff000622ff000321ff\
000222ff000224ff000326ff000427ff";
const ALPHA_RGBA_HEX: &str = "4c4950004d4a52004f4c5400524f570056545c005c596100625f683169666f80706d77bc77757ede7e7c86df84828cbe\
898892798d8c96148f8e99008f8f99008e8f98008c8d9600888a930084868f0080828a007b7f8600787c8300757a8000\
74797f00747a7e00747b7f00757d8000777f8100798083707a8284d27b8385ff504d5500514e56005451580057545c00\
5b586000605d660066636c466d6a739574717bd37b7882f6827f8af9888690d98d8b9696918f9a3293929c0093939d00\
92929c008f909a008c8d96008889920083868e007f828a007c808600797e8400787d8200777d8200787e820079808300\
7b8285077c84868e7e8588f07e8688ff59555d005a565e005c5860005f5c64006360680068656d106e6b746c74717abe\
7b7882fd827f89ff898690ff8f8c97ff94929ccd9796a06c9998a2009a99a3009998a2009696a00092939c008e909800\
8a8c94008688900082868c0080848a007e8388007e8388007f8488008086890081888b41838a8cc6848b8eff858c8eff\
646067006561680067636b006a666e006e6a7200736e774178747d9f7e7b84f485818bff8c8892ff928f99ff98959fff\
9d9aa5ffa09ea8bca2a0ab45a2a1ab00a1a0aa009e9ea8009b9ba4009797a00092939c008e9097008b8d9400888b9100\
878b9000868b8f00878c9000888e91008a9093908b9294ff8d9395ff8d9496ff706b7300716c7400736e760075717900\
79747d197e79827a837e87da89848eff908b95ff96919cff9c98a2ffa29ea8ffa6a2adffa9a6b1ffaba8b3a9aba9b325\
aaa8b200a7a6af00a3a2ac009f9fa8009b9ba30096989f0093959c00909399008f9297008f9297008f93970091959956\
92979aec94999cff959b9dff969b9eff7b757d007c767e007e788000807a8300847e874f88828bb28d8791ff938d97ff\
99939dff9f99a4ffa59faaffaaa5afffaea9b4ffb1adb7ffb3aeb9ffb2afb996b1aeb80eaeacb500aaa8b200a6a5ae00\
a1a1a9009d9da5009a9ba10097999f0096989d0096989d0096999d1f979b9eb9999da0ff9b9fa1ff9ca0a3ff9da1a3ff\
847d8500857e8600867f880089828a248c858e7f908992e4948d97ff9a939dff9f98a3ffa59ea9ffaaa4afffafa9b4ff\
b3adb8ffb6b0bbffb7b2bdffb7b2bdffb5b1bb84b2aeb802aeabb500aaa7b000a5a3ac00a1a0a7009d9da4009b9ba100\
9a9aa000999a9f009a9ca0839b9da1ff9d9fa2ff9ea1a4ff9fa3a5ffa0a3a6ff898189008a828a008b838c008d858e48\
908891a4948b95ff989099ff9d959fffa29aa4ffa79faaffaca4afffb0a9b4ffb4adb8ffb6afbaffb7b1bcffb7b1bbff\
b5afbaeeb2adb773aea9b301a9a5ae00a5a1aa00a09ea6009d9ba2009a999f0099989e0098989d5299999ed89a9b9fff\
9c9da0ff9d9fa2ff9fa0a3ff9fa1a3ff8a8089008a818a008b828b118d848d5d908690ba938a93ff978d97ff9b929cff\
a096a1ffa49ba6ffa9a0abffada4afffb0a8b3ffb2aab5ffb2abb6ffb2abb5ffb0a9b3ffaca6b0d2a8a3ac64a49fa808\
9f9ba3009b979f0097949b00959298009391972f9391969a939296ff949497ff969699ff97989aff99999cff999a9cff\
867b8400867c8500877d8618897e87638b808abf8e838dff918790ff958a95ff998f99ff9d939effa197a2ffa49ba6ff\
a79ea9ffa9a0abffa9a0abffa8a0abffa69ea8ffa29ba5ff9e97a1b199939c57958f9717908b93008d888f008a868c1f\
88858b6788858aca88868aff8a888bff8b8a8dff8d8c8eff8e8d8fff8e8e90ff7e737b007e737c007f747d1080757e5a\
827780b5847983ff877c86ff8a7f89ff8e838dff928791ff958a95ff988d99ff9b909bff9c919dff9c929dff9b919cff\
988f9aff948c96ff908892e68b848d8e8680884c827c83297e7980267b777d467a757b8579757ade7a767aff7b787bff\
7c7a7dff7e7b7eff7f7d7fff7f7e80ff73677000746770007468710075697246766a749f786c76ff7b6e78ff7d717cff\
80747fff847882ff877b86ff897e89ff8b808bff8c818cff8c818cff8a808bff887e88ff847a84ff7f7680ff7a727bac\
756e766a716a71436d666d3c6a646a546863688b686368da686468ff696568ff6a676aff6c696bff6d6a6cff6e6b6dff\
675b6400685b6400685b6400695c652a6a5d66816b5e68e26d606aff6f626dff726570ff746873ff776a76ff796d78ff\
7b6e7aff7b6f7bff7b6f7aff796e79ff766b76ff726872ff6e646dff695f68b6645b63735f575e495b545a3c5851574e\
5650557b565054c1565054ff575255ff585356ff595557ff5a5658ff5b5759ff5c4f58005c4f58005c4f58005d4f580d\
5d50595f5e515bbd60525cff61545eff645661ff665963ff685b66ff6a5d68ff6b5e6aff6b5f6aff6b5e69ff695d68ff\
665a65ff625761ff5d525cff584e57b05349516c4e454d404a42482e47404538453e435d443e4299443e42e4454042ff\
464144ff484345ff494446ff494546ff52454d0052454e0052454e0052454e0053454e4153464f99544751f7564852ff\
584a54ff594c57ff5b4e59ff5d4f5bff5e515cff5e515cff5d505bff5b4f59ff584c56ff544852ff4f444df14a3f489f\
453b435c40373e2c3c333a163931361a372f3437362f3369362f33ab363033f5383234ff393335ff3a3536ff3a3536ff\
4b3e46004b3e46004b3d46004b3d46004b3d47294b3e477b4c3e48d34d404aff4f414bff50434dff52444fff534651ff\
544752ff544752ff534651ff51454fff4e424cff4a3e48ff453a43db40353e8b3b313847362c331632292f002f262b00\
2c25290f2b2428392b2528722c2628b32d2729f42e282aff2f2a2bff302a2bff483a4300473a4300473a4300473a4300\
4739431d473a4367483a44b7483b45ff4a3c46ff4b3e48ff4c3f4aff4e404bff4e414cff4e414cff4d414bff4b3f49ff\
483c46ff443942ff3f343dc53a303878352b333530272e032c24290029212600271f2300261f2210261f22412620227a\
272123b3282324e6292424ff2a2425ff473a4300473a4300473a4300463942004639421d4639425f463943a6473a44f0\
483b45ff4a3d47ff4b3e48ff4c3f4aff4d404bff4d404bff4c404aff4a3e48ff473c45ff433841ff3f343cb43a30376a\
352b322930272d002c23290029212500271f2300251f2100251f211b2620214d26212280272222ae282323d0292424e2\
493d4500493d4500493c4500483c4500483c4429483b4562483c45a1493c46e14a3d47ff4b3f49ff4c404aff4e424cff\
4f434dff4f434dff4e434cff4c414bff4a3f48ff463c44f2423840aa3d333b63382f3624332b31002f282c002c252900\
2a2327002923250029232405292425312a25255f2b2626882c2727a72c2727b74c4149004c4149004c4049004b404817\
4b40483f4b3f486f4b4049a44c404adc4d414bff4e434dff50444eff514650ff524751ff534852ff524851ff514750ff\
4f454dff4b424aea473e46a7433a41653e363c293a323700362f3300332c3000312b2d00302a2c002f2a2b00302b2b27\
302c2c50312d2d75322e2d91322f2ea04f454c0c4e454c154e444c254e444c3d4d434b5d4d434c834e434cae4e444ddc\
50454fff514750ff534952ff554b54ff564d56ff574e57ff574e57ff564d56ff544b54ff514951e74e464daa4a42486e\
453e4437413b3f0a3e373b003b35380039343600373334003733340b3834342e38353454393635763a37358f3a37369d\
4f464e454f464e4a4e464d564e454d674e454d7e4e454d9a4e454ebc4f464fe0514851ff534a53ff554c55ff574e58ff\
59515aff5b525bff5b535bff5b535bff595159ff574f57e8534d53b250494f7c4c464b4a4842472144404305423d4000\
403c3d003f3b3c083f3c3c233f3c3c44403d3c67413e3d86413f3d9e42403eab4b444b7b4b444b7e4b444b854b434b8f\
4b434b9e4b434bb14c444cc84d454ee34f4750ff514a52ff544c55ff574f58ff59525aff5b545cff5c555dff5c555dff\
5b555cff59535aea575157bb534e538b504b4f604c484c3c49454823474345184542431b4442422b4442414444434164\
45444285464543a3464643ba474644c6433e44ac433e44ac433d44ae433d44b2433d44b8433e45c3443f46d1464048e3\
48424af84b454dff4e4851ff524c54ff554f57ff575159ff59535bff5a545cff59545bff58535aeb565257c3534f549b\
504d51764d4a4d584a484a444846473c46454541454544524545446c4646448b474744ac484845c9484946df494946ea\
363239d4363239d2363238cf363239cd363239cc37333ace39343bd33b363edd3d3940ea413c44fa444048ff48444bff\
4c474fff4f4b52ff514d54ff534f56ff535056ff524f55e8514e53c84e4c50a74c4a4d8949484a714746476345444560\
434343684343427a43444295434542b5444643d5454744f2464844ff464844ff252228f1252228ed252228e7252229de\
262329d627242ad028252cce2b282fd02e2b32d7322f36e136333aee3a373ffb3f3c43ff424047ff46434aff48454cff\
49464cf548474ce147464bc8464548af43434698414143873f3f417e3d3e3f7f3c3e3d8b3c3e3ca03c3e3cbc3d3f3cdd\
3e403dfe3e423eff3f423eff40433fff100f15ff100f15fe100f15f4110f15e7111016d8131117cc15131ac218161dbd\
1b1920be201e25c324222acd29282fd82e2c33e3333138eb36353bee39383eec3b3a3fe33b3a3fd53b3a3fc4393a3db2\
38383ba236373997343636943235349a323433a9313433c1323532e0323633ff333734ff343934ff353a35ff363a35ff\
000000ff000000ff000000f8000000e7000001d4000003c1000005b1030209a707060da20c0b12a4111017aa16161db4\
1c1b22bf212027c925252bcf28282ed12b2b30ce2c2c31c62c2d30bc2b2c2fb02a2b2ea7282a2ca227292aa4252928ae\
252827c2252927dc252a27fd262b27ff272c28ff282d29ff292e29ff292f2aff000000ff000000ff000000f7000000e2\
000000ca000000b30000009e0000008f00000086000000840000058904040b910a0a119d0f1016a814151bb118191eb7\
1a1c21b91c1d22b61c1e22b11c1e21ab1b1e20a81a1d1ea8191c1caf181c1bbd171c1ad4171c1af1181d1aff191f1bff\
1a201bff1b211cff1c221dff1c231dff000000ff000000ff000000f2000000da000000bf000000a40000008c00000079\
0000006e000000690000006c000000740000017f0001078c05060c97090b10a00c0e13a50e1014a70f1115a70f1214a6\
0e1213a60d1112ab0c1110b50b100fc70b100ee00b110eff0c120eff0d130fff0e1510ff0f1611ff101712ff101812ff\
000000ff000000ff000000ed000000d3000000b6000000990000007e000000690000005b00000055000000560000005e\
0000006a00000077000001840000058f0104089704060a9c05080b9e05080ba004080aa4030809ab030807b9020706cd\
020806e8020805ff030a06ff040b06ff050c07ff060e08ff070f09ff080f09ff000000ff000000fd000000ea000000cf\
000000b1000000920000007600000060000000510000004a0000004b000000520000005e0000006c0000007a00000086\
0000038f00010595000306990003069d000305a2000304ab000302ba000301cf000301ec000401ff000501ff000602ff\
000803ff010904ff020a04ff030b05ff";
fn b64(s: &str) -> Vec<u8> {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = Vec::new();
let mut buf = 0u32;
let mut bits = 0;
for &c in s.as_bytes() {
if c == b'=' {
break;
}
let v = T.iter().position(|&t| t == c).unwrap() as u32;
buf = (buf << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
out
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
fn check_against_swift(hash_b64: &str, expected_hex: &str, ew: u32, eh: u32) {
let hash = b64(hash_b64);
let (w, h) = dims(&hash).unwrap();
assert_eq!((w, h), (ew, eh));
let mut dst = vec![0u8; (w * h * 4) as usize];
assert!(to_rgba(&hash, &mut dst));
let expected = unhex(expected_hex);
assert_eq!(dst.len(), expected.len());
for (i, (a, b)) in dst.iter().zip(expected.iter()).enumerate() {
assert!(
(*a as i16 - *b as i16).abs() <= 1,
"byte {i}: rust={a} swift={b}"
);
}
}
#[test]
fn matches_swift_ground_truth_opaque() {
check_against_swift(OPAQUE_B64, OPAQUE_RGBA_HEX, 23, 32);
}
#[test]
fn matches_swift_ground_truth_alpha() {
check_against_swift(ALPHA_B64, ALPHA_RGBA_HEX, 32, 32);
}
#[test]
fn opaque_hash_is_fully_opaque() {
let hash = b64(OPAQUE_B64);
let (w, h) = dims(&hash).unwrap();
let mut dst = vec![0u8; (w * h * 4) as usize];
assert!(to_rgba(&hash, &mut dst));
assert!(dst.chunks_exact(4).all(|px| px[3] == 255));
}
#[test]
fn rejects_truncated_hashes_at_every_length() {
let hash = b64(ALPHA_B64);
let mut first_ok = None;
for len in 0..=hash.len() {
let cut = &hash[..len];
match dims(cut) {
Some((w, h)) => {
first_ok.get_or_insert(len);
let mut dst = vec![0u8; (w * h * 4) as usize];
assert!(to_rgba(cut, &mut dst), "len {len}");
}
None => {
assert!(first_ok.is_none(), "rejection after acceptance at {len}");
let mut dst = vec![0u8; 32 * 32 * 4];
assert!(!to_rgba(cut, &mut dst), "len {len}");
}
}
}
let first_ok = first_ok.expect("full hash must parse");
assert!(first_ok > 6, "accepted a bare header at {first_ok}");
}
#[test]
fn rejects_short_dst() {
let hash = b64(OPAQUE_B64);
let mut small = vec![0u8; 16];
assert!(!to_rgba(&hash, &mut small));
}
}

View File

@@ -0,0 +1,26 @@
[package]
name = "immich_core_ffi"
version.workspace = true
edition.workspace = true
license.workspace = true
# The build hook uses cdylib, iOS uses staticlib, and tests use lib.
[lib]
crate-type = ["lib", "cdylib", "staticlib"]
[dependencies]
immich_core = { path = "../immich_core", default-features = false, features = ["image", "thumbhash"] }
libc = { workspace = true }
tokio = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
[dev-dependencies]
libc = { workspace = true }
[build-dependencies]
cbindgen = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,22 @@
use std::path::Path;
fn main() {
// Directory entries in Cargo's dep-info make Flutter rerun the hook every build.
// Add new files here when they define exported items.
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=src/capi/mod.rs");
println!("cargo:rerun-if-changed=src/capi/image.rs");
println!("cargo:rerun-if-changed=src/capi/thumbhash.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let out = Path::new(&crate_dir).join("include").join("immich_core.h");
std::fs::create_dir_all(out.parent().unwrap()).ok();
match cbindgen::generate(&crate_dir) {
Ok(bindings) => {
bindings.write_to_file(&out);
}
Err(e) => panic!("cbindgen failed: {e}"),
}
}

View File

@@ -0,0 +1,3 @@
language = "C"
pragma_once = true
autogen_warning = "// Generated by cbindgen. Do not edit."

View File

@@ -0,0 +1,64 @@
#pragma once
// Generated by cbindgen. Do not edit.
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
/**
* Returns the version as a C string. Free it with [`immich_core_free_string`].
*/
char *immich_core_version(void);
/**
* Release a string returned by this library.
*
* # Safety
* `ptr` must be a pointer previously returned by this library, or null.
*/
void immich_core_free_string(char *ptr);
/**
* Returns whether an EXIF orientation swaps width and height.
*/
bool immich_core_orientation_swaps_dims(int32_t orientation);
/**
* Rotates RGBA8888 buffers. Invalid input or a failed operation returns false; discard `dst`.
*
* # Safety
* `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
*/
bool immich_core_rotate_rgba8888(const uint8_t *src,
uintptr_t src_len,
uintptr_t src_stride,
uint32_t width,
uint32_t height,
int32_t orientation,
uint8_t *dst,
uintptr_t dst_len);
/**
* Converts RGBA_1010102 to RGBA8888. Invalid input or a failed operation returns false; discard `dst`.
*
* # Safety
* `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
*/
bool immich_core_rgba1010102_to_rgba8888(const uint8_t *src,
uintptr_t src_len,
uintptr_t src_stride,
uint32_t width,
uint32_t height,
uint8_t *dst,
uintptr_t dst_len);
/**
* Decodes a ThumbHash into a libc buffer and writes width, height, and row bytes to `out_info`.
* Free the buffer with `free`; malformed hashes return null.
*
* # Safety
* `hash` must be valid for `hash_len` bytes and `out_info` for three u32 writes.
*/
uint8_t *immich_core_thumbhash_decode(const uint8_t *hash, uintptr_t hash_len, uint32_t *out_info);

View File

@@ -0,0 +1,17 @@
# Keep this version in sync with native/mise.toml.
[toolchain]
channel = "1.92.0"
targets = [
"armv7-linux-androideabi",
"aarch64-linux-android",
"x86_64-linux-android",
"aarch64-apple-ios",
"aarch64-apple-ios-sim",
"x86_64-apple-ios",
"aarch64-pc-windows-msvc",
"x86_64-pc-windows-msvc",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
]

View File

@@ -0,0 +1,87 @@
use jni::objects::{JClass, JObject};
use jni::sys::{jint, jlong, jobject};
use jni::{EnvUnowned, Outcome};
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_allocate<'local>(
_env: EnvUnowned<'local>,
_class: JClass<'local>,
size: jint,
) -> jlong {
// SAFETY: malloc accepts the converted size and returns null on failure.
unsafe { libc::malloc(size as usize) as jlong }
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_free<'local>(
_env: EnvUnowned<'local>,
_class: JClass<'local>,
address: jlong,
) {
// SAFETY: callers pass a libc allocation from this module, or null.
unsafe { libc::free(address as usize as *mut libc::c_void) }
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_realloc<'local>(
_env: EnvUnowned<'local>,
_class: JClass<'local>,
address: jlong,
size: jint,
) -> jlong {
// SAFETY: callers pass a libc allocation from this module, or null.
unsafe { libc::realloc(address as usize as *mut libc::c_void, size as usize) as jlong }
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_wrap<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
address: jlong,
capacity: jint,
) -> jobject {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jobject> {
// SAFETY: Kotlin keeps this allocation live while the buffer is used.
let buffer = unsafe {
env.new_direct_byte_buffer(address as usize as *mut u8, capacity as usize)
}?;
Ok(buffer.into_raw())
})
.into_outcome();
match outcome {
Outcome::Ok(buffer) => buffer,
Outcome::Err(e) => {
super::log_error(&format!("buffer wrap failed: {e}"));
std::ptr::null_mut()
}
Outcome::Panic(_) => std::ptr::null_mut(),
}
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_createGlobalRef<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
obj: JObject<'local>,
) -> jlong {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jlong> {
if obj.is_null() {
return Ok(0);
}
// This reference backs a process-wide singleton.
Ok(env.new_global_ref(&obj)?.into_raw() as jlong)
})
.into_outcome();
match outcome {
Outcome::Ok(raw) => raw,
Outcome::Err(e) => {
super::log_error(&format!("global ref failed: {e}"));
0
}
Outcome::Panic(_) => 0,
}
}

View File

@@ -0,0 +1,223 @@
use std::panic::{catch_unwind, AssertUnwindSafe};
use jni::objects::{JByteArray, JClass, JIntArray, JObject};
use jni::sys::{jint, jlong};
use jni::{Env, EnvUnowned, Outcome};
use super::jnigraphics::{
AndroidBitmapInfo, AndroidBitmap_getInfo, AndroidBitmap_lockPixels, AndroidBitmap_unlockPixels,
ANDROID_BITMAP_FORMAT_RGBA_1010102, ANDROID_BITMAP_FORMAT_RGBA_8888,
ANDROID_BITMAP_RESULT_SUCCESS,
};
fn with_bitmap_into_buffer(
env: &mut Env,
bitmap: &JObject,
out_info: &JIntArray,
expected_format: i32,
out_dims: impl FnOnce(&AndroidBitmapInfo) -> (u32, u32),
work: impl FnOnce(&AndroidBitmapInfo, &[u8], &mut [u8]) -> bool,
) -> jlong {
let raw_env = env.get_raw();
let raw_bitmap = bitmap.as_raw();
let mut info = AndroidBitmapInfo {
width: 0,
height: 0,
stride: 0,
format: 0,
flags: 0,
};
// SAFETY: raw_env/raw_bitmap come from live JNI arguments of this call.
if unsafe { AndroidBitmap_getInfo(raw_env, raw_bitmap, &mut info) }
!= ANDROID_BITMAP_RESULT_SUCCESS
|| info.format != expected_format
{
return 0;
}
let (dw, dh) = out_dims(&info);
let Some(dst_len) = (info.width as usize)
.checked_mul(info.height as usize)
.and_then(|len| len.checked_mul(4))
else {
return 0;
};
let Some(src_len) = (info.stride as usize).checked_mul(info.height as usize) else {
return 0;
};
let Ok(dw) = i32::try_from(dw) else {
return 0;
};
let Ok(dh) = i32::try_from(dh) else {
return 0;
};
let Some(row_bytes) = dw.checked_mul(4) else {
return 0;
};
if dst_len == 0
|| src_len == 0
|| dst_len > isize::MAX as usize
|| src_len > isize::MAX as usize
{
return 0;
}
// SAFETY: calloc returns dst_len zeroed bytes; callers free them through the libc heap.
let dst = unsafe { libc::calloc(dst_len, 1) } as *mut u8;
if dst.is_null() {
return 0;
}
let mut src_pixels: *mut core::ffi::c_void = std::ptr::null_mut();
// SAFETY: lock/unlock pair on every path below; the pixels stay valid between.
if unsafe { AndroidBitmap_lockPixels(raw_env, raw_bitmap, &mut src_pixels) }
!= ANDROID_BITMAP_RESULT_SUCCESS
|| src_pixels.is_null()
{
// SAFETY: dst was just allocated above and never escaped.
unsafe { libc::free(dst as *mut libc::c_void) };
return 0;
}
// Keep cleanup below reachable if the pixel operation panics.
let ok = catch_unwind(AssertUnwindSafe(|| {
// SAFETY: the locked bitmap is valid for `stride * height` bytes.
let src = unsafe { std::slice::from_raw_parts(src_pixels as *const u8, src_len) };
// SAFETY: dst points to dst_len initialized bytes and has not escaped.
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
work(&info, src, dst_slice)
}))
.unwrap_or(false);
// SAFETY: paired with the successful lock above.
unsafe { AndroidBitmap_unlockPixels(raw_env, raw_bitmap) };
if !ok {
// SAFETY: dst never escaped; free before reporting failure.
unsafe { libc::free(dst as *mut libc::c_void) };
return 0;
}
let dims = [dw, dh, row_bytes];
if out_info.set_region(env, 0, &dims).is_err() || env.exception_check() {
// SAFETY: dst never escaped.
unsafe { libc::free(dst as *mut libc::c_void) };
return 0;
}
dst as jlong
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeImage_rotate<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
bitmap: JObject<'local>,
orientation: jint,
out_info: JIntArray<'local>,
) -> jlong {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jlong> {
Ok(with_bitmap_into_buffer(
env,
&bitmap,
&out_info,
ANDROID_BITMAP_FORMAT_RGBA_8888,
|info| {
if immich_core::image::swaps_dims(orientation) {
(info.height, info.width)
} else {
(info.width, info.height)
}
},
|info, src, dst| {
immich_core::image::rotate_rgba8888(
src,
info.stride as usize,
info.width as usize,
info.height as usize,
orientation,
dst,
)
},
))
})
.into_outcome();
match outcome {
Outcome::Ok(ptr) => ptr,
Outcome::Err(e) => {
super::log_error(&format!("bitmap op failed: {e}"));
0
}
Outcome::Panic(_) => 0,
}
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeImage_convert1010102<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
bitmap: JObject<'local>,
out_info: JIntArray<'local>,
) -> jlong {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jlong> {
Ok(with_bitmap_into_buffer(
env,
&bitmap,
&out_info,
ANDROID_BITMAP_FORMAT_RGBA_1010102,
|info| (info.width, info.height),
|info, src, dst| {
immich_core::image::rgba1010102_to_rgba8888(
src,
info.stride as usize,
info.width as usize,
info.height as usize,
dst,
)
},
))
})
.into_outcome();
match outcome {
Outcome::Ok(ptr) => ptr,
Outcome::Err(e) => {
super::log_error(&format!("bitmap op failed: {e}"));
0
}
Outcome::Panic(_) => 0,
}
}
#[no_mangle]
pub extern "system" fn Java_app_alextran_immich_NativeImage_thumbhash<'local>(
mut env: EnvUnowned<'local>,
_class: JClass<'local>,
hash: JByteArray<'local>,
out_info: JIntArray<'local>,
) -> jlong {
crate::log::ensure_panic_hook();
let outcome = env
.with_env(|env| -> jni::errors::Result<jlong> {
let hash = env.convert_byte_array(&hash)?;
let Some((dst, w, h)) = crate::capi::thumbhash::decode_malloc(&hash) else {
return Ok(0);
};
let dims = [w as i32, h as i32, w as i32 * 4];
if out_info.set_region(env, 0, &dims).is_err() {
// SAFETY: dst never escaped; free before reporting failure.
unsafe { libc::free(dst as *mut libc::c_void) };
return Ok(0);
}
Ok(dst as jlong)
})
.into_outcome();
match outcome {
Outcome::Ok(ptr) => ptr,
Outcome::Err(e) => {
super::log_error(&format!("thumbhash decode failed: {e}"));
0
}
Outcome::Panic(_) => 0,
}
}

View File

@@ -0,0 +1,29 @@
use jni::sys::jobject;
#[repr(C)]
pub(super) struct AndroidBitmapInfo {
pub width: u32,
pub height: u32,
pub stride: u32,
pub format: i32,
pub flags: u32,
}
pub(super) const ANDROID_BITMAP_RESULT_SUCCESS: i32 = 0;
pub(super) const ANDROID_BITMAP_FORMAT_RGBA_8888: i32 = 1;
pub(super) const ANDROID_BITMAP_FORMAT_RGBA_1010102: i32 = 10;
#[link(name = "jnigraphics")]
extern "C" {
pub(super) fn AndroidBitmap_getInfo(
env: *mut jni::sys::JNIEnv,
jbitmap: jobject,
info: *mut AndroidBitmapInfo,
) -> i32;
pub(super) fn AndroidBitmap_lockPixels(
env: *mut jni::sys::JNIEnv,
jbitmap: jobject,
addr_ptr: *mut *mut core::ffi::c_void,
) -> i32;
pub(super) fn AndroidBitmap_unlockPixels(env: *mut jni::sys::JNIEnv, jbitmap: jobject) -> i32;
}

View File

@@ -0,0 +1,16 @@
use std::ffi::CString;
const ANDROID_LOG_ERROR: i32 = 6;
#[link(name = "log")]
extern "C" {
fn __android_log_write(prio: i32, tag: *const libc::c_char, text: *const libc::c_char) -> i32;
}
pub(crate) fn log_error(msg: &str) {
let Ok(text) = CString::new(msg.replace('\0', " ")) else {
return;
};
// SAFETY: both pointers are live NUL-terminated strings for the call's duration.
unsafe { __android_log_write(ANDROID_LOG_ERROR, c"immich_core".as_ptr(), text.as_ptr()) };
}

View File

@@ -0,0 +1,7 @@
// Native buffers must stay on libc's heap: Dart frees them with malloc.free and Kotlin uses realloc.
mod buffer;
mod image;
mod jnigraphics;
mod log;
pub(crate) use log::log_error;

View File

@@ -0,0 +1,86 @@
/// Runs a pixel operation on caller-owned buffers. Invalid input or a failed operation returns false; discard `dst`.
///
/// # Safety
/// `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
pub(super) unsafe fn fill_dst(
src: *const u8,
src_len: usize,
dst: *mut u8,
dst_len: usize,
op: impl FnOnce(&[u8], &mut [u8]) -> bool,
) -> bool {
crate::log::ensure_panic_hook();
if src.is_null() || dst.is_null() {
return false;
}
// SAFETY: guaranteed by the caller.
let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) };
// SAFETY: guaranteed by the caller.
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| op(src_slice, dst_slice)))
.unwrap_or(false)
}
/// Returns whether an EXIF orientation swaps width and height.
#[no_mangle]
pub extern "C" fn immich_core_orientation_swaps_dims(orientation: i32) -> bool {
super::guard(false, || immich_core::image::swaps_dims(orientation))
}
/// Rotates RGBA8888 buffers. Invalid input or a failed operation returns false; discard `dst`.
///
/// # Safety
/// `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
#[no_mangle]
pub unsafe extern "C" fn immich_core_rotate_rgba8888(
src: *const u8,
src_len: usize,
src_stride: usize,
width: u32,
height: u32,
orientation: i32,
dst: *mut u8,
dst_len: usize,
) -> bool {
// SAFETY: guaranteed by this function's caller.
unsafe {
fill_dst(src, src_len, dst, dst_len, |s, d| {
immich_core::image::rotate_rgba8888(
s,
src_stride,
width as usize,
height as usize,
orientation,
d,
)
})
}
}
/// Converts RGBA_1010102 to RGBA8888. Invalid input or a failed operation returns false; discard `dst`.
///
/// # Safety
/// `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
#[no_mangle]
pub unsafe extern "C" fn immich_core_rgba1010102_to_rgba8888(
src: *const u8,
src_len: usize,
src_stride: usize,
width: u32,
height: u32,
dst: *mut u8,
dst_len: usize,
) -> bool {
// SAFETY: guaranteed by this function's caller.
unsafe {
fill_dst(src, src_len, dst, dst_len, |s, d| {
immich_core::image::rgba1010102_to_rgba8888(
s,
src_stride,
width as usize,
height as usize,
d,
)
})
}
}

View File

@@ -0,0 +1,65 @@
pub mod image;
pub mod thumbhash;
use std::ffi::{c_char, CString};
use std::ptr;
/// Returns the version as a C string. Free it with [`immich_core_free_string`].
#[no_mangle]
pub extern "C" fn immich_core_version() -> *mut c_char {
guard(ptr::null_mut(), || {
into_c_string(immich_core::core_version().to_owned())
})
}
/// Release a string returned by this library.
///
/// # Safety
/// `ptr` must be a pointer previously returned by this library, or null.
#[no_mangle]
pub unsafe extern "C" fn immich_core_free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
guard((), || {
// SAFETY: guaranteed by the caller.
let s = unsafe { CString::from_raw(ptr) };
drop(s);
});
}
fn guard<T>(sentinel: T, f: impl FnOnce() -> T + std::panic::UnwindSafe) -> T {
crate::log::ensure_panic_hook();
std::panic::catch_unwind(f).unwrap_or(sentinel)
}
fn into_c_string(s: String) -> *mut c_char {
match CString::new(s) {
Ok(c) => c.into_raw(),
Err(_) => ptr::null_mut(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use std::ffi::CStr;
#[test]
fn version_roundtrips_and_frees() {
let p = immich_core_version();
assert!(!p.is_null());
// SAFETY: p is a C string returned by this library.
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
assert!(!s.is_empty());
// SAFETY: p was returned by this library and is freed once.
unsafe { immich_core_free_string(p) };
}
#[test]
fn free_null_is_noop() {
// SAFETY: null is allowed by the function contract.
unsafe { immich_core_free_string(ptr::null_mut()) };
}
}

View File

@@ -0,0 +1,54 @@
use std::panic::{catch_unwind, AssertUnwindSafe};
pub(crate) fn decode_malloc(hash: &[u8]) -> Option<(*mut u8, u32, u32)> {
let (w, h) = immich_core::thumbhash::dims(hash)?;
let len = w as usize * h as usize * 4;
// SAFETY: calloc returns len zeroed bytes; callers free them through the libc heap.
let dst = unsafe { libc::calloc(len, 1) } as *mut u8;
if dst.is_null() {
return None;
}
// SAFETY: dst points to len initialized bytes and has not escaped.
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, len) };
let ok = catch_unwind(AssertUnwindSafe(|| {
immich_core::thumbhash::to_rgba(hash, dst_slice)
}))
.unwrap_or(false);
if !ok {
// SAFETY: dst never escaped.
unsafe { libc::free(dst as *mut libc::c_void) };
return None;
}
Some((dst, w, h))
}
/// Decodes a ThumbHash into a libc buffer and writes width, height, and row bytes to `out_info`.
/// Free the buffer with `free`; malformed hashes return null.
///
/// # Safety
/// `hash` must be valid for `hash_len` bytes and `out_info` for three u32 writes.
#[no_mangle]
pub unsafe extern "C" fn immich_core_thumbhash_decode(
hash: *const u8,
hash_len: usize,
out_info: *mut u32,
) -> *mut u8 {
crate::log::ensure_panic_hook();
if hash.is_null() || out_info.is_null() {
return std::ptr::null_mut();
}
// SAFETY: guaranteed by the caller.
let hash_slice = unsafe { std::slice::from_raw_parts(hash, hash_len) };
match catch_unwind(|| decode_malloc(hash_slice)) {
Ok(Some((dst, w, h))) => {
// SAFETY: guaranteed by the caller.
unsafe {
*out_info = w;
*out_info.add(1) = h;
*out_info.add(2) = w * 4;
}
dst
}
_ => std::ptr::null_mut(),
}
}

View File

@@ -0,0 +1,40 @@
use std::ffi::{c_char, c_void, CString};
const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
fn CFStringCreateWithCString(
alloc: *const c_void,
c_str: *const c_char,
encoding: u32,
) -> *const c_void;
fn CFRelease(cf: *const c_void);
}
#[link(name = "Foundation", kind = "framework")]
extern "C" {
fn NSLog(format: *const c_void, ...);
}
pub(crate) fn log_error(msg: &str) {
let Ok(text) = CString::new(format!("immich_core: {}", msg.replace('\0', " "))) else {
return;
};
// SAFETY: both CFStrings stay live through NSLog and are released below.
unsafe {
let format =
CFStringCreateWithCString(std::ptr::null(), c"%@".as_ptr(), K_CF_STRING_ENCODING_UTF8);
let message =
CFStringCreateWithCString(std::ptr::null(), text.as_ptr(), K_CF_STRING_ENCODING_UTF8);
if !format.is_null() && !message.is_null() {
NSLog(format, message);
}
if !message.is_null() {
CFRelease(message);
}
if !format.is_null() {
CFRelease(format);
}
}
}

View File

@@ -0,0 +1,3 @@
mod log;
pub(crate) use log::log_error;

View File

@@ -0,0 +1,21 @@
//! Mobile FFI bindings for `immich_core`.
#![deny(clippy::unwrap_used, clippy::expect_used)]
mod capi;
mod log;
pub mod runtime;
/// cbindgen:ignore
#[cfg(target_os = "android")]
mod android;
/// cbindgen:ignore
#[cfg(target_os = "ios")]
mod ios;
pub use capi::image::{
immich_core_orientation_swaps_dims, immich_core_rgba1010102_to_rgba8888,
immich_core_rotate_rgba8888,
};
pub use capi::thumbhash::immich_core_thumbhash_decode;
pub use capi::{immich_core_free_string, immich_core_version};

View File

@@ -0,0 +1,32 @@
use std::sync::Once;
#[cfg(target_os = "android")]
use crate::android::log_error;
#[cfg(target_os = "ios")]
use crate::ios::log_error;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn log_error(msg: &str) {
eprintln!("immich_core: {msg}");
}
pub(crate) fn ensure_panic_hook() {
static HOOK: Once = Once::new();
HOOK.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let msg = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
.unwrap_or("panic");
let location = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_default();
log_error(&format!("panic at {location}: {msg}"));
previous(info);
}));
});
}

View File

@@ -0,0 +1,40 @@
//! Shared Tokio runtime for FFI work.
use std::sync::LazyLock;
use tokio::runtime::{Builder, Runtime};
static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
Builder::new_multi_thread()
.worker_threads(2)
.thread_name("immich-core")
.enable_time()
.build()
.unwrap_or_else(|e| panic!("immich-core runtime failed to start: {e}"))
});
/// Returns the process-wide runtime.
pub fn runtime() -> &'static Runtime {
&RUNTIME
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn runtime_is_shared_and_reusable() {
let a = runtime() as *const Runtime;
let b = runtime() as *const Runtime;
assert_eq!(a, b);
let first = runtime().block_on(async { 21 * 2 });
assert_eq!(first, 42);
let handle = runtime().spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
7
});
assert_eq!(runtime().block_on(handle).unwrap(), 7);
}
}

View File

@@ -0,0 +1,213 @@
#![allow(clippy::unwrap_used, clippy::undocumented_unsafe_blocks)]
use std::ffi::CStr;
use std::ptr;
use immich_core_ffi::{
immich_core_free_string, immich_core_orientation_swaps_dims,
immich_core_rgba1010102_to_rgba8888, immich_core_rotate_rgba8888, immich_core_thumbhash_decode,
immich_core_version,
};
const THUMBHASH: [u8; 21] = [
0xd5, 0x07, 0x12, 0x1d, 0x04, 0x67, 0x87, 0x8f, 0x77, 0x57, 0x87, 0x48, 0x87, 0x87, 0x97, 0x87,
0x58, 0x78, 0x90, 0x95, 0x08,
];
#[test]
fn version_is_a_valid_c_string_and_frees() {
let p = immich_core_version();
assert!(!p.is_null());
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
assert!(!s.is_empty());
assert!(s.chars().next().unwrap().is_ascii_digit());
unsafe { immich_core_free_string(p) };
}
#[test]
fn free_string_accepts_null() {
unsafe { immich_core_free_string(ptr::null_mut()) };
}
#[test]
fn swaps_dims_matches_the_exif_family() {
for o in [5, 6, 7, 8] {
assert!(immich_core_orientation_swaps_dims(o), "o={o}");
}
for o in [-1, 0, 1, 2, 3, 4, 9] {
assert!(!immich_core_orientation_swaps_dims(o), "o={o}");
}
}
#[test]
fn rotate_180_matches_expected_bytes() {
let src: [u8; 8] = [255, 0, 0, 255, 0, 255, 0, 255];
let mut dst = [0u8; 8];
let ok = unsafe {
immich_core_rotate_rgba8888(src.as_ptr(), src.len(), 8, 2, 1, 3, dst.as_mut_ptr(), 8)
};
assert!(ok);
assert_eq!(dst, [0, 255, 0, 255, 255, 0, 0, 255]);
}
#[test]
fn rotate_90_swaps_output_dims() {
let src: [u8; 8] = [255, 0, 0, 255, 0, 255, 0, 255];
let mut dst = [0u8; 8];
let ok = unsafe {
immich_core_rotate_rgba8888(src.as_ptr(), src.len(), 8, 2, 1, 6, dst.as_mut_ptr(), 8)
};
assert!(ok);
assert_eq!(dst, [255, 0, 0, 255, 0, 255, 0, 255]);
}
#[test]
fn rotate_rejects_bad_input_without_touching_dst() {
let src = [0u8; 16];
let mut dst = [0xAAu8; 16];
unsafe {
assert!(!immich_core_rotate_rgba8888(
ptr::null(),
16,
8,
2,
2,
3,
dst.as_mut_ptr(),
16
));
assert!(!immich_core_rotate_rgba8888(
src.as_ptr(),
16,
8,
2,
2,
3,
ptr::null_mut(),
16
));
assert!(!immich_core_rotate_rgba8888(
src.as_ptr(),
8,
8,
2,
2,
3,
dst.as_mut_ptr(),
16
));
assert!(!immich_core_rotate_rgba8888(
src.as_ptr(),
16,
8,
2,
2,
3,
dst.as_mut_ptr(),
8
));
}
assert!(dst.iter().all(|&b| b == 0xAA));
}
#[test]
fn convert_matches_skia_ground_truth() {
// 179 and 111 distinguish rounded scaling from `>> 2`.
let px = |r: u32, g: u32, b: u32, a: u32| -> [u8; 4] {
((r & 0x3FF) | ((g & 0x3FF) << 10) | ((b & 0x3FF) << 20) | ((a & 0x3) << 30)).to_le_bytes()
};
let mut src = Vec::new();
src.extend_from_slice(&px(1023, 0, 0, 3));
src.extend_from_slice(&px(179, 111, 0, 3));
let mut dst = [0u8; 8];
let ok = unsafe {
immich_core_rgba1010102_to_rgba8888(src.as_ptr(), src.len(), 8, 2, 1, dst.as_mut_ptr(), 8)
};
assert!(ok);
assert_eq!(&dst[0..4], &[255, 0, 0, 255]);
assert_eq!(&dst[4..8], &[45, 28, 0, 255]);
}
#[test]
fn convert_rejects_bad_input_without_touching_dst() {
let src = [0u8; 16];
let mut dst = [0xAAu8; 16];
unsafe {
assert!(!immich_core_rgba1010102_to_rgba8888(
ptr::null(),
16,
8,
2,
2,
dst.as_mut_ptr(),
16
));
assert!(!immich_core_rgba1010102_to_rgba8888(
src.as_ptr(),
16,
8,
2,
2,
ptr::null_mut(),
16
));
assert!(!immich_core_rgba1010102_to_rgba8888(
src.as_ptr(),
8,
8,
2,
2,
dst.as_mut_ptr(),
16
));
assert!(!immich_core_rgba1010102_to_rgba8888(
src.as_ptr(),
16,
8,
2,
2,
dst.as_mut_ptr(),
8
));
}
assert!(dst.iter().all(|&b| b == 0xAA));
}
#[test]
fn thumbhash_decodes_into_a_malloc_buffer() {
let mut info = [0u32; 3];
let ptr = unsafe {
immich_core_thumbhash_decode(THUMBHASH.as_ptr(), THUMBHASH.len(), info.as_mut_ptr())
};
assert!(!ptr.is_null());
assert_eq!(info, [23, 32, 92]);
let len = (info[0] * info[1] * 4) as usize;
let pixels = unsafe { std::slice::from_raw_parts(ptr, len) };
assert!(pixels.chunks_exact(4).all(|px| px[3] == 255));
assert!(pixels.chunks_exact(4).any(|px| px[0] != pixels[0]));
unsafe { libc::free(ptr as *mut libc::c_void) };
}
#[test]
fn thumbhash_rejects_bad_input() {
let mut info = [7u32; 3];
unsafe {
assert!(immich_core_thumbhash_decode(ptr::null(), 21, info.as_mut_ptr()).is_null());
assert!(immich_core_thumbhash_decode(THUMBHASH.as_ptr(), 4, info.as_mut_ptr()).is_null());
assert!(
immich_core_thumbhash_decode(THUMBHASH.as_ptr(), THUMBHASH.len(), ptr::null_mut())
.is_null()
);
}
assert_eq!(info, [7, 7, 7]);
}
#[test]
fn runtime_is_shared_across_external_callers() {
let a = immich_core_ffi::runtime::runtime() as *const _;
let b = immich_core_ffi::runtime::runtime() as *const _;
assert_eq!(a, b);
let out = immich_core_ffi::runtime::runtime().block_on(async { 7 * 6 });
assert_eq!(out, 42);
}

View File

@@ -0,0 +1,19 @@
[package]
name = "immich_core_napi"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
immich_core = { path = "../immich_core", default-features = false }
napi = { workspace = true, features = ["napi4", "dyn-symbols"] }
napi-derive = { workspace = true }
[build-dependencies]
napi-build = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}

View File

@@ -0,0 +1,8 @@
//! Node binding for `immich_core`.
use napi_derive::napi;
#[napi]
pub fn core_version() -> String {
immich_core::core_version().to_owned()
}

24
native/immich_native_core/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
*.iml
*.ipr
*.iws
.idea/
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/

View File

@@ -0,0 +1,9 @@
# immich_native_core (Flutter package)
Build hook and Dart FFI bindings for the Immich native core.
The hook builds `immich_core_ffi` from source and bundles it as a Flutter code
asset. Builders need `rustup`; the Rust version and targets are pinned in the
FFI crate's `rust-toolchain.toml`.
Workspace commands are in [`../README.md`](../README.md).

View File

@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml

View File

@@ -0,0 +1,17 @@
# Keep asset-id in sync with the build hook.
name: ImmichNativeCoreBindings
ffi-native:
asset-id: 'package:immich_native_core/src/ffi/bindings.g.dart'
description: 'Generated FFI bindings to immich_native_core.'
output: 'lib/src/ffi/bindings.g.dart'
headers:
entry-points:
- '../crates/immich_core_ffi/include/immich_core.h'
include-directives:
- '**/immich_core.h'
functions:
include:
- 'immich_core_.*'
comments:
style: any
length: full

View File

@@ -0,0 +1,106 @@
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 _buildDependencies = [
'../Cargo.toml',
'../Cargo.lock',
'../crates/immich_core/Cargo.toml',
'../crates/immich_core_ffi/Cargo.toml',
'../crates/immich_core_ffi/rust-toolchain.toml',
'../crates/immich_core_ffi/build.rs',
'../crates/immich_core_ffi/cbindgen.toml',
];
void main(List<String> args) async {
await build(args, (input, output) async {
if (!input.config.buildCodeAssets) return;
output.dependencies.addAll(
_buildDependencies.map(input.packageRoot.resolve),
);
await _ensureRustTarget(input);
final rustFlags = _rustFlags(input);
await RustBuilder(
assetName: 'src/ffi/bindings.g.dart',
cratePath: '../crates/immich_core_ffi',
// Keep 16 KB alignment and override native_toolchain_rust's API 35 target.
extraCargoEnvironmentVariables: {'RUSTFLAGS': ?rustFlags},
).run(input: input, output: output);
});
}
String? _rustFlags(BuildInput input) {
final code = input.config.code;
if (code.targetOS != OS.android) return null;
final triple = switch (code.targetArchitecture) {
Architecture.arm => 'armv7a-linux-androideabi',
Architecture.arm64 => 'aarch64-linux-android',
Architecture.x64 => 'x86_64-linux-android',
_ => throw UnsupportedError(
'Unsupported Android architecture: ${code.targetArchitecture}',
),
};
final api = code.android.targetNdkApi;
return '-C link-arg=-Wl,-z,max-page-size=16384 '
'-C link-arg=--target=$triple$api';
}
Future<void> _ensureRustTarget(BuildInput input) async {
final code = input.config.code;
final triple = switch (code.targetOS) {
OS.android => switch (code.targetArchitecture) {
Architecture.arm => 'armv7-linux-androideabi',
Architecture.arm64 => 'aarch64-linux-android',
Architecture.x64 => 'x86_64-linux-android',
_ => null,
},
OS.iOS => switch ((code.targetArchitecture, code.iOS.targetSdk)) {
(Architecture.arm64, IOSSdk.iPhoneSimulator) => 'aarch64-apple-ios-sim',
(Architecture.arm64, _) => 'aarch64-apple-ios',
(Architecture.x64, _) => 'x86_64-apple-ios',
_ => null,
},
OS.macOS => switch (code.targetArchitecture) {
Architecture.arm64 => 'aarch64-apple-darwin',
Architecture.x64 => 'x86_64-apple-darwin',
_ => null,
},
OS.windows => switch (code.targetArchitecture) {
Architecture.arm64 => 'aarch64-pc-windows-msvc',
Architecture.x64 => 'x86_64-pc-windows-msvc',
_ => null,
},
OS.linux => switch (code.targetArchitecture) {
Architecture.arm64 => 'aarch64-unknown-linux-gnu',
Architecture.x64 => 'x86_64-unknown-linux-gnu',
_ => null,
},
_ => null,
};
if (triple == null) return;
final crate = input.packageRoot.resolve('../crates/immich_core_ffi/');
final toolchainFile = crate.resolve('rust-toolchain.toml');
final source = await File.fromUri(toolchainFile).readAsString();
final channel = RegExp(
r'^\s*channel\s*=\s*"([^"]+)"\s*$',
multiLine: true,
).firstMatch(source)?.group(1);
if (channel == null) {
throw FormatException('Missing toolchain channel in $toolchainFile');
}
final args = ['target', 'add', '--toolchain', channel, triple];
final result = await Process.run(
'rustup',
args,
workingDirectory: crate.toFilePath(),
);
final out = '${result.stdout}';
final err = '${result.stderr}';
if (out.isNotEmpty) stdout.write(out);
if (err.isNotEmpty) stderr.write(err);
if (result.exitCode != 0) {
throw ProcessException('rustup', args, err, result.exitCode);
}
}

View File

@@ -0,0 +1,3 @@
library;
export 'src/ffi/bindings.g.dart';

View File

@@ -0,0 +1,93 @@
// AUTO GENERATED FILE, DO NOT EDIT.
//
// Generated by `package:ffigen`.
// ignore_for_file: type=lint, unused_import
@ffi.DefaultAsset('package:immich_native_core/src/ffi/bindings.g.dart')
library;
import 'dart:ffi' as ffi;
/// Returns the version as a C string. Free it with [`immich_core_free_string`].
@ffi.Native<ffi.Pointer<ffi.Char> Function()>()
external ffi.Pointer<ffi.Char> immich_core_version();
/// Release a string returned by this library.
///
/// # Safety
/// `ptr` must be a pointer previously returned by this library, or null.
@ffi.Native<ffi.Void Function(ffi.Pointer<ffi.Char>)>()
external void immich_core_free_string(ffi.Pointer<ffi.Char> ptr);
/// Returns whether an EXIF orientation swaps width and height.
@ffi.Native<ffi.Bool Function(ffi.Int32)>()
external bool immich_core_orientation_swaps_dims(int orientation);
/// Rotates RGBA8888 buffers. Invalid input or a failed operation returns false; discard `dst`.
///
/// # Safety
/// `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
@ffi.Native<
ffi.Bool Function(
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
ffi.UintPtr,
ffi.Uint32,
ffi.Uint32,
ffi.Int32,
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
)
>()
external bool immich_core_rotate_rgba8888(
ffi.Pointer<ffi.Uint8> src,
int src_len,
int src_stride,
int width,
int height,
int orientation,
ffi.Pointer<ffi.Uint8> dst,
int dst_len,
);
/// Converts RGBA_1010102 to RGBA8888. Invalid input or a failed operation returns false; discard `dst`.
///
/// # Safety
/// `src` and `dst` must be valid for their lengths, initialized, and must not overlap.
@ffi.Native<
ffi.Bool Function(
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
ffi.UintPtr,
ffi.Uint32,
ffi.Uint32,
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
)
>()
external bool immich_core_rgba1010102_to_rgba8888(
ffi.Pointer<ffi.Uint8> src,
int src_len,
int src_stride,
int width,
int height,
ffi.Pointer<ffi.Uint8> dst,
int dst_len,
);
/// Decodes a ThumbHash into a libc buffer and writes width, height, and row bytes to `out_info`.
/// Free the buffer with `free`; malformed hashes return null.
///
/// # Safety
/// `hash` must be valid for `hash_len` bytes and `out_info` for three u32 writes.
@ffi.Native<
ffi.Pointer<ffi.Uint8> Function(
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
ffi.Pointer<ffi.Uint32>,
)
>()
external ffi.Pointer<ffi.Uint8> immich_core_thumbhash_decode(
ffi.Pointer<ffi.Uint8> hash,
int hash_len,
ffi.Pointer<ffi.Uint32> out_info,
);

View File

@@ -0,0 +1,23 @@
name: immich_native_core
description: "Dart FFI bindings to the Immich native core."
version: 0.1.0
homepage: https://github.com/immich-app/immich
publish_to: none
environment:
sdk: '>=3.11.0 <4.0.0'
flutter: '>=3.38.0'
dependencies:
flutter:
sdk: flutter
code_assets: ^1.2.1
hooks: ^2.0.2
native_toolchain_rust: ^1.0.4
dev_dependencies:
ffi: ^2.2.0
ffigen: 20.1.1 # Keep generated bindings stable.
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0

View File

@@ -0,0 +1,136 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_native_core/immich_native_core.dart';
Uint8List _px1010102(int r, int g, int b, int a) {
final px =
(r & 0x3FF) |
((g & 0x3FF) << 10) |
((b & 0x3FF) << 20) |
((a & 0x3) << 30);
return Uint8List(4)..buffer.asByteData().setUint32(0, px, Endian.little);
}
typedef _ImageFn =
bool Function(Pointer<Uint8> src, int dstLen, Pointer<Uint8> dst);
Uint8List? _withBuffers(Uint8List src, int dstLen, _ImageFn call) {
final srcPtr = calloc<Uint8>(src.length);
final dstPtr = calloc<Uint8>(dstLen);
try {
srcPtr.asTypedList(src.length).setAll(0, src);
if (!call(srcPtr, dstLen, dstPtr)) {
return null;
}
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
} finally {
calloc.free(srcPtr);
calloc.free(dstPtr);
}
}
void main() {
test('loads the core', () {
final ptr = immich_core_version();
expect(ptr, isNot(equals(nullptr)));
final version = ptr.cast<Utf8>().toDartString();
immich_core_free_string(ptr);
expect(version, isNotEmpty);
});
test('reports swapped orientations', () {
for (final o in [5, 6, 7, 8]) {
expect(immich_core_orientation_swaps_dims(o), isTrue, reason: 'o=$o');
}
for (final o in [0, 1, 2, 3, 4, 9]) {
expect(immich_core_orientation_swaps_dims(o), isFalse, reason: 'o=$o');
}
});
test('rotates RGBA pixels', () {
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
final r180 = _withBuffers(
src,
8,
(s, len, d) =>
immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 3, d, len),
);
expect(r180, [0, 255, 0, 255, 255, 0, 0, 255]);
final r90 = _withBuffers(
src,
8,
(s, len, d) =>
immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 6, d, len),
);
expect(r90, isNotNull);
});
test('rotate declines bad sizes instead of writing', () {
final src = Uint8List(16);
final tooSmall = _withBuffers(
src,
4,
(s, len, d) =>
immich_core_rotate_rgba8888(s, src.length, 8, 2, 2, 6, d, len),
);
expect(tooSmall, isNull);
});
test('converts RGBA_1010102 pixels', () {
// 179 and 111 distinguish rounded scaling from `>> 2`.
final src = Uint8List.fromList([
..._px1010102(1023, 0, 0, 3),
..._px1010102(179, 111, 0, 3),
]);
final out = _withBuffers(
src,
8,
(s, len, d) =>
immich_core_rgba1010102_to_rgba8888(s, src.length, 8, 2, 1, d, len),
);
expect(out, isNotNull);
expect(out!.sublist(0, 4), [255, 0, 0, 255]);
expect(out.sublist(4, 8), [45, 28, 0, 255]);
});
test('convert declines bad sizes instead of writing', () {
final src = Uint8List(16);
final badStride = _withBuffers(
src,
16,
(s, len, d) =>
immich_core_rgba1010102_to_rgba8888(s, src.length, 4, 2, 2, d, len),
);
expect(badStride, isNull);
});
test('decodes a thumbhash', () {
final hash = base64Decode('1QcSHQRnh493V4dIh4eXh1h4kJUI');
final hashPtr = malloc<Uint8>(hash.length);
final info = malloc<Uint32>(3);
try {
hashPtr.asTypedList(hash.length).setAll(0, hash);
final ptr = immich_core_thumbhash_decode(hashPtr, hash.length, info);
expect(ptr, isNot(equals(nullptr)));
expect((info[0], info[1], info[2]), (23, 32, 23 * 4));
final len = info[0] * info[1] * 4;
final pixels = ptr.asTypedList(len);
for (var i = 3; i < len; i += 4) {
expect(pixels[i], 255, reason: 'alpha at $i');
}
expect(pixels.toSet().length, greaterThan(2));
malloc.free(ptr);
expect(immich_core_thumbhash_decode(hashPtr, 4, info), equals(nullptr));
} finally {
malloc.free(hashPtr);
malloc.free(info);
}
});
}

33
native/mise.lock Normal file
View File

@@ -0,0 +1,33 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.6"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.6-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.6-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.6-stable.zip"
[[tools.rust]]
version = "1.92.0"
backend = "core:rust"
[tools.rust.options]
components = "clippy,rustfmt"

72
native/mise.toml Normal file
View File

@@ -0,0 +1,72 @@
[tools]
"aqua:flutter/flutter" = "3.44.6" # keep in sync with ../mobile/mise.toml
rust = { version = "1.92.0", components = "rustfmt,clippy" } # keep in sync with rust-toolchain.toml (the build hook uses rustup)
[tasks.build]
description = "Build all native core crates (host)"
run = "cargo build --workspace"
[tasks.test]
description = "Run native core Rust tests"
run = "cargo test --workspace"
[tasks.fmt]
description = "Format all crates"
run = "cargo fmt --all"
[tasks.lint]
description = "Clippy (warnings = errors)"
run = "cargo clippy --workspace --all-targets -- -D warnings"
[tasks."codegen:ffigen"]
alias = "codegen"
description = "Generate the C header (cbindgen) + Dart @Native bindings (ffigen)"
sources = [
"Cargo.toml",
"Cargo.lock",
"crates/immich_core/Cargo.toml",
"crates/immich_core_ffi/Cargo.toml",
"crates/immich_core_ffi/rust-toolchain.toml",
"crates/immich_core_ffi/build.rs",
"crates/immich_core_ffi/src/**/*.rs",
"crates/immich_core_ffi/cbindgen.toml",
"immich_native_core/ffigen.yaml",
]
outputs = [
"crates/immich_core_ffi/include/immich_core.h",
"immich_native_core/lib/src/ffi/bindings.g.dart",
]
run = [
"cargo build --locked -p immich_core_ffi",
"cd immich_native_core && dart run ffigen --config ffigen.yaml && dart format lib/src/ffi/bindings.g.dart",
]
[tasks."test:flutter"]
description = "Host FFI roundtrip via the build hook (flutter test)"
dir = "immich_native_core"
run = "flutter test"
[tasks."build:dart"]
description = "Build the dart:ffi cdylib directly (host, raw cargo)"
run = "cargo build -p immich_core_ffi"
[tasks."build:napi"]
description = "Build the node addon + stage a .node for require() (server, unwired)"
run = [
"cargo build -p immich_core_napi --release",
"cp target/release/libimmich_core_napi.dylib smoke/immich_core_napi.node 2>/dev/null || cp target/release/libimmich_core_napi.so smoke/immich_core_napi.node",
]
[tasks."smoke:dart"]
description = "Host dart:ffi ABI roundtrip (raw DynamicLibrary on the cdylib)"
depends = ["build:dart"]
run = "dart run smoke/dart_smoke.dart"
[tasks."smoke:node"]
description = "Host napi roundtrip"
depends = ["build:napi"]
run = "node smoke/node_smoke.mjs"
[tasks.smoke]
description = "Rust tests + host dart:ffi + host napi roundtrips"
depends = ["test", "smoke:dart", "smoke:node"]

20
native/scripts/build-linux.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Requires cargo-zigbuild and Zig on PATH.
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v cargo-zigbuild >/dev/null; then
echo "cargo-zigbuild is required" >&2
exit 1
fi
CRATE=immich_core_napi
for t in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
rustup target add "$t" >/dev/null 2>&1 || true
cargo zigbuild -p "$CRATE" --target "$t" --release
mkdir -p "dist/server/$t"
cp "target/$t/release/lib${CRATE}.so" "dist/server/$t/immich_core_napi.node"
done
echo "linux -> dist/server/*/immich_core_napi.node"

View File

@@ -0,0 +1,40 @@
import 'dart:ffi';
import 'dart:io';
typedef _VersionNative = Pointer<Uint8> Function();
typedef _FreeNative = Void Function(Pointer<Uint8>);
typedef _FreeDart = void Function(Pointer<Uint8>);
String _readCString(Pointer<Uint8> p) {
final bytes = <int>[];
for (var i = 0; p[i] != 0; i++) {
bytes.add(p[i]);
}
return String.fromCharCodes(bytes);
}
void main(List<String> args) {
final name = Platform.isMacOS
? 'libimmich_core_ffi.dylib'
: Platform.isLinux
? 'libimmich_core_ffi.so'
: Platform.isWindows
? 'immich_core_ffi.dll'
: throw UnsupportedError('Unsupported host: ${Platform.operatingSystem}');
final libPath = args.isNotEmpty
? args.first
: File.fromUri(Platform.script.resolve('../target/debug/$name')).path;
final lib = DynamicLibrary.open(libPath);
final version = lib.lookupFunction<_VersionNative, _VersionNative>(
'immich_core_version',
);
final free = lib.lookupFunction<_FreeNative, _FreeDart>(
'immich_core_free_string',
);
final ptr = version();
print('DART core_version = ${_readCString(ptr)}');
free(ptr);
print('DART roundtrip OK');
}

View File

@@ -0,0 +1,12 @@
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const core = require('./immich_core_napi.node');
const version = core.coreVersion();
console.log(`NAPI core_version = ${version}`);
if (!version) {
console.error('NAPI empty version');
process.exit(1);
}
console.log('NAPI roundtrip OK');