mirror of
https://github.com/immich-app/immich.git
synced 2026-07-21 21:34:17 +03:00
Compare commits
22 Commits
fix/backup
...
feat/nativ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da42ca655 | ||
|
|
8b22917228 | ||
|
|
2786cc16a1 | ||
|
|
ce718e6c10 | ||
|
|
9b15a1cfa8 | ||
|
|
ae284a6c30 | ||
|
|
110c8e1a0f | ||
|
|
13a050fc67 | ||
|
|
ef359c02f1 | ||
|
|
f899d33120 | ||
|
|
f53477e474 | ||
|
|
b2b5841472 | ||
|
|
c0f9c50abe | ||
|
|
7d27eeceb6 | ||
|
|
bca556b6c5 | ||
|
|
ead25d62b5 | ||
|
|
8734066187 | ||
|
|
d2580e9d53 | ||
|
|
07043c4af1 | ||
|
|
fec11ab156 | ||
|
|
3919887c2a | ||
|
|
bb8e242fbe |
54
.github/workflows/build-mobile.yml
vendored
54
.github/workflows/build-mobile.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/static_analysis.yml
vendored
1
.github/workflows/static_analysis.yml
vendored
@@ -35,6 +35,7 @@ jobs:
|
||||
mobile:
|
||||
- 'mobile/**'
|
||||
- 'i18n/en.json'
|
||||
- 'native/**'
|
||||
force-filters: |
|
||||
- '.github/workflows/static_analysis.yml'
|
||||
force-events: 'workflow_dispatch,release'
|
||||
|
||||
43
.github/workflows/test.yml
vendored
43
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
/machine-learning/ @mertalev
|
||||
/e2e/ @danieldietzler
|
||||
/mobile/ @shenlong-tanwen @santoshakil @agg23
|
||||
/native/ @santoshakil @mertalev
|
||||
|
||||
@@ -12,6 +12,7 @@ config_roots = [
|
||||
"docs",
|
||||
".github",
|
||||
"machine-learning",
|
||||
"native",
|
||||
]
|
||||
|
||||
[tools]
|
||||
|
||||
@@ -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)
|
||||
@@ -77,11 +77,6 @@ android {
|
||||
}
|
||||
namespace 'app.alextran.immich'
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path "CMakeLists.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
mobile/integration_test/native_core_test.dart
Normal file
97
mobile/integration_test/native_core_test.dart
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
194
mobile/integration_test/native_jni_test.dart
Normal file
194
mobile/integration_test/native_jni_test.dart
Normal 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)');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 */,
|
||||
|
||||
57
mobile/ios/Runner/Core/NativeCore.swift
Normal file
57
mobile/ios/Runner/Core/NativeCore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
56
mobile/test/utils/hooks/blurhash_hook_test.dart
Normal file
56
mobile/test/utils/hooks/blurhash_hook_test.dart
Normal 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
2
native/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
smoke/*.node
|
||||
693
native/Cargo.lock
generated
Normal file
693
native/Cargo.lock
generated
Normal 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
34
native/Cargo.toml
Normal 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
29
native/README.md
Normal 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
|
||||
```
|
||||
13
native/crates/immich_core/Cargo.toml
Normal file
13
native/crates/immich_core/Cargo.toml
Normal 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
|
||||
310
native/crates/immich_core/src/image.rs
Normal file
310
native/crates/immich_core/src/image.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
21
native/crates/immich_core/src/lib.rs
Normal file
21
native/crates/immich_core/src/lib.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
481
native/crates/immich_core/src/thumbhash.rs
Normal file
481
native/crates/immich_core/src/thumbhash.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
26
native/crates/immich_core_ffi/Cargo.toml
Normal file
26
native/crates/immich_core_ffi/Cargo.toml
Normal 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
|
||||
22
native/crates/immich_core_ffi/build.rs
Normal file
22
native/crates/immich_core_ffi/build.rs
Normal 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}"),
|
||||
}
|
||||
}
|
||||
3
native/crates/immich_core_ffi/cbindgen.toml
Normal file
3
native/crates/immich_core_ffi/cbindgen.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
language = "C"
|
||||
pragma_once = true
|
||||
autogen_warning = "// Generated by cbindgen. Do not edit."
|
||||
64
native/crates/immich_core_ffi/include/immich_core.h
Normal file
64
native/crates/immich_core_ffi/include/immich_core.h
Normal 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);
|
||||
17
native/crates/immich_core_ffi/rust-toolchain.toml
Normal file
17
native/crates/immich_core_ffi/rust-toolchain.toml
Normal 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",
|
||||
]
|
||||
87
native/crates/immich_core_ffi/src/android/buffer.rs
Normal file
87
native/crates/immich_core_ffi/src/android/buffer.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
223
native/crates/immich_core_ffi/src/android/image.rs
Normal file
223
native/crates/immich_core_ffi/src/android/image.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
29
native/crates/immich_core_ffi/src/android/jnigraphics.rs
Normal file
29
native/crates/immich_core_ffi/src/android/jnigraphics.rs
Normal 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;
|
||||
}
|
||||
16
native/crates/immich_core_ffi/src/android/log.rs
Normal file
16
native/crates/immich_core_ffi/src/android/log.rs
Normal 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()) };
|
||||
}
|
||||
7
native/crates/immich_core_ffi/src/android/mod.rs
Normal file
7
native/crates/immich_core_ffi/src/android/mod.rs
Normal 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;
|
||||
86
native/crates/immich_core_ffi/src/capi/image.rs
Normal file
86
native/crates/immich_core_ffi/src/capi/image.rs
Normal 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
65
native/crates/immich_core_ffi/src/capi/mod.rs
Normal file
65
native/crates/immich_core_ffi/src/capi/mod.rs
Normal 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()) };
|
||||
}
|
||||
}
|
||||
54
native/crates/immich_core_ffi/src/capi/thumbhash.rs
Normal file
54
native/crates/immich_core_ffi/src/capi/thumbhash.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
40
native/crates/immich_core_ffi/src/ios/log.rs
Normal file
40
native/crates/immich_core_ffi/src/ios/log.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
native/crates/immich_core_ffi/src/ios/mod.rs
Normal file
3
native/crates/immich_core_ffi/src/ios/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod log;
|
||||
|
||||
pub(crate) use log::log_error;
|
||||
21
native/crates/immich_core_ffi/src/lib.rs
Normal file
21
native/crates/immich_core_ffi/src/lib.rs
Normal 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};
|
||||
32
native/crates/immich_core_ffi/src/log.rs
Normal file
32
native/crates/immich_core_ffi/src/log.rs
Normal 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);
|
||||
}));
|
||||
});
|
||||
}
|
||||
40
native/crates/immich_core_ffi/src/runtime.rs
Normal file
40
native/crates/immich_core_ffi/src/runtime.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
213
native/crates/immich_core_ffi/tests/c_abi.rs
Normal file
213
native/crates/immich_core_ffi/tests/c_abi.rs
Normal 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);
|
||||
}
|
||||
19
native/crates/immich_core_napi/Cargo.toml
Normal file
19
native/crates/immich_core_napi/Cargo.toml
Normal 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
|
||||
3
native/crates/immich_core_napi/build.rs
Normal file
3
native/crates/immich_core_napi/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
8
native/crates/immich_core_napi/src/lib.rs
Normal file
8
native/crates/immich_core_napi/src/lib.rs
Normal 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
24
native/immich_native_core/.gitignore
vendored
Normal 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/
|
||||
9
native/immich_native_core/README.md
Normal file
9
native/immich_native_core/README.md
Normal 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).
|
||||
1
native/immich_native_core/analysis_options.yaml
Normal file
1
native/immich_native_core/analysis_options.yaml
Normal file
@@ -0,0 +1 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
17
native/immich_native_core/ffigen.yaml
Normal file
17
native/immich_native_core/ffigen.yaml
Normal 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
|
||||
106
native/immich_native_core/hook/build.dart
Normal file
106
native/immich_native_core/hook/build.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
3
native/immich_native_core/lib/immich_native_core.dart
Normal file
3
native/immich_native_core/lib/immich_native_core.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
library;
|
||||
|
||||
export 'src/ffi/bindings.g.dart';
|
||||
93
native/immich_native_core/lib/src/ffi/bindings.g.dart
Normal file
93
native/immich_native_core/lib/src/ffi/bindings.g.dart
Normal 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,
|
||||
);
|
||||
23
native/immich_native_core/pubspec.yaml
Normal file
23
native/immich_native_core/pubspec.yaml
Normal 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
|
||||
136
native/immich_native_core/test/native_core_test.dart
Normal file
136
native/immich_native_core/test/native_core_test.dart
Normal 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
33
native/mise.lock
Normal 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
72
native/mise.toml
Normal 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
20
native/scripts/build-linux.sh
Executable 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"
|
||||
40
native/smoke/dart_smoke.dart
Normal file
40
native/smoke/dart_smoke.dart
Normal 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');
|
||||
}
|
||||
12
native/smoke/node_smoke.mjs
Normal file
12
native/smoke/node_smoke.mjs
Normal 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');
|
||||
Reference in New Issue
Block a user