mirror of
https://github.com/immich-app/immich.git
synced 2026-07-16 13:44:28 +03:00
Compare commits
18 Commits
main
...
feat/nativ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b15a1cfa8 | ||
|
|
ae284a6c30 | ||
|
|
110c8e1a0f | ||
|
|
13a050fc67 | ||
|
|
ef359c02f1 | ||
|
|
f899d33120 | ||
|
|
f53477e474 | ||
|
|
b2b5841472 | ||
|
|
c0f9c50abe | ||
|
|
7d27eeceb6 | ||
|
|
bca556b6c5 | ||
|
|
ead25d62b5 | ||
|
|
8734066187 | ||
|
|
d2580e9d53 | ||
|
|
07043c4af1 | ||
|
|
fec11ab156 | ||
|
|
3919887c2a | ||
|
|
bb8e242fbe |
@@ -5,3 +5,4 @@
|
||||
/machine-learning/ @mertalev
|
||||
/e2e/ @danieldietzler
|
||||
/mobile/ @shenlong-tanwen @santoshakil @agg23
|
||||
/native/ @santoshakil @mertalev
|
||||
|
||||
@@ -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,8 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024
|
||||
|
||||
object NativeBuffer {
|
||||
init {
|
||||
System.loadLibrary("native_buffer")
|
||||
// All native code lives in the shared Rust core (built by the Flutter build hook).
|
||||
System.loadLibrary("immich_core_ffi")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@@ -21,9 +22,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
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import android.graphics.Bitmap
|
||||
|
||||
object NativeImage {
|
||||
init {
|
||||
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
|
||||
System.loadLibrary("native_buffer")
|
||||
// The image functions are JNI exports of the shared Rust core.
|
||||
System.loadLibrary("immich_core_ffi")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,4 +26,12 @@ object NativeImage {
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
||||
|
||||
/**
|
||||
* Decodes a ThumbHash placeholder into a freshly malloc'd RGBA_8888 native buffer. Returns the
|
||||
* buffer address (free it with [NativeBuffer.free]) and fills [outInfo] with
|
||||
* {width, height, rowBytes}. Returns 0 when the hash is malformed.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun thumbhash(hash: ByteArray, outInfo: IntArray): Long
|
||||
}
|
||||
|
||||
@@ -107,12 +107,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
mobile/integration_test/native_core_test.dart
Normal file
110
mobile/integration_test/native_core_test.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
// Plumbing check: proves immich_native_core is usable from the real immich app on
|
||||
// a real device — the build hook compiled the Rust for this target, the code asset
|
||||
// bundled into the app, and the @Native symbols resolve at runtime. The payloads
|
||||
// run against their device-verified ground truth: the EXIF rotate ported from
|
||||
// native_image.c (#29337), the 10-bit convert matching Skia's Bitmap.copy (#29631),
|
||||
// and the thumbhash decode. Calls the generated bindings directly — dart is the
|
||||
// test harness here; the production callers are the platform decode pipelines.
|
||||
// Self-contained: does NOT boot the immich app or need a server.
|
||||
//
|
||||
// Run: flutter test integration_test/native_core_test.dart -d <device>
|
||||
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 = malloc<Uint8>(src.length);
|
||||
final dstPtr = malloc<Uint8>(dstLen);
|
||||
try {
|
||||
srcPtr.asTypedList(src.length).setAll(0, src);
|
||||
if (!call(srcPtr, dstLen, dstPtr)) {
|
||||
return null;
|
||||
}
|
||||
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
|
||||
} finally {
|
||||
malloc.free(srcPtr);
|
||||
malloc.free(dstPtr);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('native core loads: version roundtrips through the C string contract', () {
|
||||
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('orientation swaps dims exactly for the 90/270/transpose family', () {
|
||||
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('exif rotate (the #29337 algorithm) rotates 180 on device', () {
|
||||
// 2x1: red, green -> 180 -> green, red
|
||||
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('10-bit convert (the #29631 algorithm) matches Skia ground truth on device', () {
|
||||
// 179->45 and 111->28 pin round(v*255/1023) over >>2 (44/27) — the exact
|
||||
// discriminating values probed on this hardware against Bitmap.copy.
|
||||
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('thumbhash decodes via the core into a malloc buffer', () {
|
||||
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);
|
||||
|
||||
// malformed hash: null return, info untouched
|
||||
expect(immich_core_thumbhash_decode(hashPtr, 4, info), equals(nullptr));
|
||||
} finally {
|
||||
malloc.free(hashPtr);
|
||||
malloc.free(info);
|
||||
}
|
||||
});
|
||||
}
|
||||
138
mobile/integration_test/native_jni_test.dart
Normal file
138
mobile/integration_test/native_jni_test.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
// JNI-layer check: proves the Rust core's Java_app_alextran_immich_* exports work
|
||||
// under the real JVM by driving the production kotlin callers through pigeon —
|
||||
// getThumbhash (NativeBuffer.allocate/wrap), requestImage preferEncoded
|
||||
// (allocate/wrap + ByteBuffer.put), and a full 10-bit AVIF decode
|
||||
// (toNativeBuffer -> NativeImage.convert1010102). Buffers come back as raw
|
||||
// addresses and are freed from dart with malloc.free — the libc-heap handoff the
|
||||
// whole design depends on. Android only: iOS has no JNI layer.
|
||||
//
|
||||
// The fixture is a 327-byte 64x64 solid-color 10-bit AVIF (yuv420p10le, bt709,
|
||||
// limited range) that decodes to ~(45, 139, 107, 255). On API 33+ it decodes to
|
||||
// RGBA_1010102 and exercises the rust convert; a device that decodes 10-bit
|
||||
// straight to 8888 yields the same colors, so the assertions hold either way.
|
||||
// Misreading 1010102 as rgba8888 (the #24906 bug) would give R~176 and A~218 —
|
||||
// far outside the tolerance.
|
||||
//
|
||||
// Run: flutter test integration_test/native_jni_test.dart -d <android-device>
|
||||
// Teardown deletes the seeded fixture, which on API 30+ shows the system
|
||||
// delete-consent dialog once. All assertions complete before teardown, so the
|
||||
// pass is settled by then; a headless/CI run just has to confirm the dialog, e.g.
|
||||
// adb shell uiautomator dump /sdcard/ui.xml # then tap the "Allow" node bounds
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
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';
|
||||
|
||||
Uint8List _read(int address, int length) => Uint8List.fromList(Pointer<Uint8>.fromAddress(address).asTypedList(length));
|
||||
|
||||
// The kotlin side allocates with libc malloc; freeing from dart via package:ffi
|
||||
// hits the same process-global libc free. This IS the production ownership flow.
|
||||
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;
|
||||
|
||||
setUpAll(() async {
|
||||
await PhotoManager.setIgnorePermissionCheck(true);
|
||||
final entity = await PhotoManager.editor.saveImage(fixture, filename: 'immich_jni_fixture.avif');
|
||||
assetId = entity.id;
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
if (assetId != null) {
|
||||
try {
|
||||
await PhotoManager.editor.deleteWithIds([assetId!]);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
test('thumbhash decodes through NativeBuffer allocate+wrap and dart frees it', () 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']!);
|
||||
// Deterministic math into a correctly wrapped buffer: two runs byte-identical,
|
||||
// and a real image decodes to more than one color.
|
||||
expect(pixelsA, pixelsB);
|
||||
expect(pixelsA.toSet().length, greaterThan(1));
|
||||
});
|
||||
|
||||
test('encoded request roundtrips the file bytes through the native buffer', () 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 lands correct colors through NativeImage.convert1010102', () async {
|
||||
final Map<String, int>? res;
|
||||
try {
|
||||
res = await api.requestImage(
|
||||
assetId!,
|
||||
requestId: 900002,
|
||||
width: 0, // unsized -> full-res decode, the load-original path
|
||||
height: 0,
|
||||
isVideo: false,
|
||||
preferEncoded: false,
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
// Some devices cannot decode 10-bit AVIF at all (e.g. SM-X115 throws
|
||||
// "getPixels failed" before toNativeBuffer runs) — nothing to assert there.
|
||||
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']!);
|
||||
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)');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 */; };
|
||||
@@ -130,7 +129,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 */
|
||||
@@ -349,7 +347,6 @@
|
||||
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
|
||||
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
|
||||
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */,
|
||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
|
||||
);
|
||||
path = Images;
|
||||
sourceTree = "<group>";
|
||||
@@ -646,7 +643,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 */,
|
||||
|
||||
29
mobile/ios/Runner/Core/NativeCore.swift
Normal file
29
mobile/ios/Runner/Core/NativeCore.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
// Loads the shared Rust core (immich_core_ffi) — the Swift counterpart of
|
||||
// Kotlin's `System.loadLibrary`. Flutter's native-assets build embeds the
|
||||
// framework in the bundle without linking Runner against it, so symbols are
|
||||
// resolved at runtime. Signatures mirror native/crates/immich_core_ffi/include/immich_core.h.
|
||||
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 handle: UnsafeMutableRawPointer? = {
|
||||
if let frameworks = Bundle.main.privateFrameworksPath {
|
||||
let path = "\(frameworks)/immich_core_ffi.framework/immich_core_ffi"
|
||||
if let handle = dlopen(path, RTLD_NOW) {
|
||||
return handle
|
||||
}
|
||||
}
|
||||
// Fall back to the process scope (dart or a test host already loaded it).
|
||||
return dlopen(nil, RTLD_NOW)
|
||||
}()
|
||||
|
||||
private static func symbol<T>(_ name: String) -> T? {
|
||||
guard let handle, let sym = dlsym(handle, name) else { return nil }
|
||||
return unsafeBitCast(sym, to: T.self)
|
||||
}
|
||||
}
|
||||
@@ -38,15 +38,21 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||
ImageProcessing.queue.addOperation {
|
||||
guard let data = Data(base64Encoded: thumbhash)
|
||||
guard let data = Data(base64Encoded: thumbhash), let decode = NativeCore.thumbhashDecode
|
||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||
|
||||
let (width, height, pointer) = thumbHashToRGBA(hash: data)
|
||||
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: "", message: "Invalid thumbhash: \(thumbhash)", details: nil)))}
|
||||
|
||||
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,15 @@ platform :ios do
|
||||
)
|
||||
end
|
||||
|
||||
# Xcode script phases don't inherit MISE_TRUSTED_CONFIG_PATHS, and the mise shim
|
||||
# for rustup refuses untrusted configs, which kills the native assets build hook.
|
||||
# `mise trust` persists trust to disk so it survives into the xcode environment.
|
||||
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 +111,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 +269,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 +297,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: {
|
||||
|
||||
@@ -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,11 @@
|
||||
[tools]
|
||||
"aqua:flutter/flutter" = "3.44.1"
|
||||
java = "21.0.2"
|
||||
# immich_native_core builds from source via a dart build hook that drives rustup.
|
||||
# mise bootstraps rustup + this toolchain; keep in sync with the crate's rust-toolchain.toml.
|
||||
# targets are preinstalled here because mise exports RUSTUP_TOOLCHAIN, which makes
|
||||
# rustup ignore the crate's rust-toolchain.toml (and its target list) at build time.
|
||||
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:
|
||||
@@ -1756,6 +1771,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
|
||||
|
||||
5
native/.gitignore
vendored
Normal file
5
native/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
smoke/*.node
|
||||
# generated + committed (regen via `mise run codegen`):
|
||||
# crates/immich_core_ffi/include/immich_core.h (cbindgen)
|
||||
# immich_native_core/lib/src/ffi/bindings.g.dart (ffigen)
|
||||
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"
|
||||
50
native/Cargo.toml
Normal file
50
native/Cargo.toml
Normal file
@@ -0,0 +1,50 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/immich_core",
|
||||
"crates/immich_core_ffi",
|
||||
"crates/immich_core_napi",
|
||||
]
|
||||
|
||||
# shared logic lives in immich_core (no binding deps). each binding crate is a
|
||||
# thin wrapper that picks its own crate-type: immich_core_ffi -> cdylib/staticlib,
|
||||
# the C ABI consumed by dart (ffigen), swift (C interop) and kotlin (JNI shim);
|
||||
# immich_core_napi -> cdylib (.node) for the node server.
|
||||
# capabilities (image, ...) are cargo features on immich_core so both
|
||||
# bindings opt into the same set. crate-type can't be feature-gated, which is why
|
||||
# the bindings are separate crates rather than one crate with feature flags.
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
# single source of truth for all external dep versions. inner crates reference
|
||||
# these with `{ workspace = true }` and never hardcode a version.
|
||||
# default-features = false MUST live here (workspace level) — cargo ignores it if
|
||||
# set only on the inner crate. inner crates then add the minimal features they need.
|
||||
[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 }
|
||||
|
||||
# enforced by `mise run lint` (clippy -D warnings); the boundary crate also
|
||||
# #![deny]s unwrap/expect.
|
||||
[workspace.lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
|
||||
# NB: no `panic = "abort"` — the FFI boundary relies on catch_unwind, which is a
|
||||
# no-op under abort. default unwind is what lets a boundary panic become a null
|
||||
# return instead of taking down the host (Flutter app / node server).
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
73
native/README.md
Normal file
73
native/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# immich_native_core
|
||||
|
||||
Shared Rust core for the pixel work the platforms can't do without native code,
|
||||
built from source into the app via Flutter native assets. It replaces the android
|
||||
C layer (`native_buffer.c`/`native_image.c`) and the per-platform thumbhash ports
|
||||
(`ThumbHash.java`/`Thumbhash.swift`), so the same logic exists once, tested:
|
||||
- **EXIF-orientation rotate** — from `native_image.c` (#29337; fixes #24796,
|
||||
sideways RAW photos). Byte-for-byte the same affine + tiled copy, plus bounds
|
||||
checks the raw C can't have.
|
||||
- **RGBA_1010102 → RGBA8888 convert** — the 10-bit HEIC/AVIF color fix (#29631,
|
||||
fixes #24906). Same `round(v*255/1023)` LUT + packing as the C, proven on-device
|
||||
against Skia's `Bitmap.copy(ARGB_8888)`.
|
||||
- **ThumbHash decode** — one bounds-checked implementation instead of the two
|
||||
platform ports, which disagreed on rounding and crashed on truncated hashes.
|
||||
Both platforms now render identical placeholders.
|
||||
|
||||
The image ops fill a caller-owned output buffer (no allocation at the boundary)
|
||||
because the production callers hold JNI-locked bitmaps; the thumbhash decode
|
||||
returns a libc allocation the consumer frees with plain `free`.
|
||||
|
||||
## Layout
|
||||
```
|
||||
crates/
|
||||
immich_core pure logic, no binding deps. capabilities = cargo features
|
||||
(image, thumbhash).
|
||||
immich_core_ffi the hand-written C ABI + cbindgen header — consumed by dart
|
||||
(ffigen), swift (C interop) and kotlin (JNI shims in src/android/)
|
||||
immich_core_napi cdylib (.node) via napi-rs (server, unwired)
|
||||
immich_native_core/ the Flutter package mobile depends on. build hook + ffigen @Native bindings.
|
||||
smoke/ host dart + node roundtrip scripts (no device)
|
||||
```
|
||||
Bindings are separate crates (Cargo can't gate `crate-type` by feature).
|
||||
|
||||
## How the native lib is built (Flutter native assets — no prebuilt, no CI)
|
||||
`immich_native_core/hook/build.dart` (`native_toolchain_rust`) compiles
|
||||
`crates/immich_core_ffi` **from source on every app build** via rustup and bundles
|
||||
it as a Flutter *code asset*. The Dart side uses ffigen `@Native` externals bound to
|
||||
that asset — no `DynamicLibrary`, no prebuilt artifacts, no fetch/publish/separate-repo.
|
||||
|
||||
Native assets is on by default on Flutter stable (3.38+), so a stock `flutter build`
|
||||
runs the hook. Each builder needs **rustup** (the hook auto-installs the pinned
|
||||
toolchain + targets from `crates/immich_core_ffi/rust-toolchain.toml`).
|
||||
|
||||
## Dev commands (mise)
|
||||
```
|
||||
mise run build cargo build --workspace
|
||||
mise run test cargo test --workspace (host Rust tests, incl. FFI-boundary)
|
||||
mise run lint clippy -D warnings (fmt: mise run fmt)
|
||||
mise run codegen regen cbindgen header + ffigen @Native bindings — commit the result
|
||||
mise run test:flutter HOST FFI roundtrip through the real build hook (no device)
|
||||
mise run smoke Rust tests + host dart:ffi + host napi roundtrips
|
||||
```
|
||||
|
||||
## Add a capability (end to end)
|
||||
1. add the logic to `crates/immich_core` (behind a cargo feature if it pulls a dep).
|
||||
2. expose a C entry in `crates/immich_core_ffi/src/capi/` — `#[no_mangle] pub extern "C"`,
|
||||
wrap the body in `guard(...)`/`catch_unwind` (panic at the boundary → sentinel, never
|
||||
unwind into the host), validate pointers, and either fill a caller-owned buffer or
|
||||
return Rust-owned memory freed via `immich_core_free_string`.
|
||||
3. `mise run codegen` — regenerates the committed cbindgen header + ffigen `@Native` bindings.
|
||||
4. `mise run test:flutter` (host) + a case in `immich_native_core/test/` and in
|
||||
`mobile/integration_test/native_core_test.dart` (device). The dart surface is the
|
||||
generated bindings; add a hand-written dart wrapper only when a dart feature consumes it.
|
||||
5. platform callers: kotlin via the existing JNI shim pattern (`NativeImage.kt`), swift
|
||||
reads the same header natively.
|
||||
|
||||
## Consume from immich/mobile
|
||||
`immich_native_core: { path: ../native/immich_native_core }` in `mobile/pubspec.yaml`,
|
||||
then `dart pub get`. No app-level Gradle/Podfile edits — the hook builds + bundles the
|
||||
lib. Builders need rustup. See the package README for the iOS App-Extension caveat.
|
||||
|
||||
`/native/` is codeowned by @santoshakil + @mertalev. License: reuses the immich
|
||||
repo-root AGPL-3.0 (no separate license file).
|
||||
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 = [] # pure pixel math, no deps
|
||||
thumbhash = [] # placeholder decode, no deps
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
292
native/crates/immich_core/src/image.rs
Normal file
292
native/crates/immich_core/src/image.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
//! EXIF-orientation rotation of RGBA8888 pixel buffers, ported from the Android
|
||||
//! native_image.c (immich PR #29337). Lives here so the perf-critical pixel math
|
||||
//! exists once, tested, callable from any platform's decode pipeline (Android RAW
|
||||
//! today; the algorithm is platform-agnostic). The platform side keeps the bitmap
|
||||
//! lock + output allocation and calls this to fill the destination buffer.
|
||||
|
||||
// EXIF orientation values (androidx ExifInterface.ORIENTATION_*).
|
||||
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;
|
||||
|
||||
// 32x32 u32 tile = 4KB, L1-resident so a 90/270 transpose's scattered writes stay hot.
|
||||
const TILE: usize = 32;
|
||||
|
||||
/// Whether the orientation swaps width and height (the 90/270 + transpose family).
|
||||
pub fn swaps_dims(orientation: i32) -> bool {
|
||||
matches!(orientation, ROTATE_90 | ROTATE_270 | TRANSPOSE | TRANSVERSE)
|
||||
}
|
||||
|
||||
// (base, step_x, step_y): src pixel (sx,sy) maps to dst pixel index
|
||||
// base + sx*step_x + sy*step_y for a destination of width `dw`. Mirrors
|
||||
// native_image.c affine_for byte-for-byte. i64 so the math stays correct on 32-bit.
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate `src` (RGBA8888, `sh` rows of `src_stride` bytes, `sw` pixels per row) into
|
||||
/// `dst` (densely packed, `dw*dh*4` bytes) for the given EXIF orientation, where
|
||||
/// (dw,dh) swap for the 90/270/transpose family. Returns `false` without touching
|
||||
/// out-of-range memory if the sizes are inconsistent, so the caller can fall back.
|
||||
/// Indexing is bounds-checked: a bad input fails safe (panic caught at the FFI
|
||||
/// boundary / false here), never an out-of-bounds write like the raw C.
|
||||
pub fn rotate_rgba8888(
|
||||
src: &[u8],
|
||||
src_stride: usize,
|
||||
sw: usize,
|
||||
sh: usize,
|
||||
orientation: i32,
|
||||
dst: &mut [u8],
|
||||
) -> bool {
|
||||
if sw == 0 || sh == 0 || src_stride < sw * 4 {
|
||||
return false;
|
||||
}
|
||||
let dw = if swaps_dims(orientation) { sh } else { sw };
|
||||
let dh = if swaps_dims(orientation) { sw } else { sh };
|
||||
if src.len() < src_stride * sh || dst.len() < dw * dh * 4 {
|
||||
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
|
||||
}
|
||||
|
||||
// 10-bit -> 8-bit, matching Skia's Bitmap.copy(ARGB_8888): round(v * 255 / 1023).
|
||||
// Compile-time LUT so it's one lookup per channel, not a mul+div per pixel. The
|
||||
// integer form equals round-half-up for all 1024 inputs and v*255/1023 never lands
|
||||
// on x.5, so it's exact for every value (verified on-device against Skia).
|
||||
const SCALE10: [u8; 1024] = {
|
||||
let mut lut = [0u8; 1024];
|
||||
let mut v = 0usize;
|
||||
while v < 1024 {
|
||||
lut[v] = ((v as u32 * 255 + 511) / 1023) as u8;
|
||||
v += 1;
|
||||
}
|
||||
lut
|
||||
};
|
||||
|
||||
// 2-bit alpha -> 8-bit (a * 85). Photos decode opaque (a == 3 -> 255).
|
||||
const ALPHA2: [u8; 4] = [0, 85, 170, 255];
|
||||
|
||||
/// Convert an Android RGBA_1010102 buffer (what a 10-bit HEIC/AVIF decodes to on
|
||||
/// API 33+) to RGBA8888, byte-for-byte with Skia's `Bitmap.copy(ARGB_8888)`. Each
|
||||
/// src pixel is a little-endian u32 with R in bits 0-9, G in 10-19, B in 20-29,
|
||||
/// A in 30-31 (standard RGB10_A2 packing). `src` is `h` rows of `src_stride` bytes,
|
||||
/// `dst` the caller's densely packed `w*h*4`. Returns false on inconsistent sizes
|
||||
/// so the caller can fall back — same contract as [`rotate_rgba8888`], no alloc.
|
||||
pub fn rgba1010102_to_rgba8888(
|
||||
src: &[u8],
|
||||
src_stride: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
dst: &mut [u8],
|
||||
) -> bool {
|
||||
if w == 0 || h == 0 || src_stride < w * 4 {
|
||||
return false;
|
||||
}
|
||||
if src.len() < src_stride * h || dst.len() < w * h * 4 {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
// explicit little-endian — dodges an unaligned *const u32 read on non-x86
|
||||
let px = u32::from_le_bytes([src[s], src[s + 1], src[s + 2], src[s + 3]]);
|
||||
let d = d_row + x * 4;
|
||||
dst[d] = SCALE10[(px & 0x3FF) as usize];
|
||||
dst[d + 1] = SCALE10[((px >> 10) & 0x3FF) as usize];
|
||||
dst[d + 2] = SCALE10[((px >> 20) & 0x3FF) as usize];
|
||||
dst[d + 3] = ALPHA2[((px >> 30) & 0x3) as usize];
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Independent textbook EXIF transform: src(sx,sy) -> dst(dx,dy). Verifies the
|
||||
// affine port against orientation *semantics*, not against itself.
|
||||
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(); // 2x3 RGBA
|
||||
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); // 4 bytes row padding
|
||||
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)); // 180: i -> N-1-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)); // dst too small
|
||||
assert!(!rotate_rgba8888(&src, 4, 2, 2, 1, &mut small)); // stride < sw*4
|
||||
}
|
||||
|
||||
// On-device (Pixel 9a) vs Skia's Bitmap.copy(ARGB_8888). 179/111 rule out `>> 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, "SCALE10[{v}]");
|
||||
}
|
||||
assert_eq!(ALPHA2, [0, 85, 170, 255]);
|
||||
}
|
||||
|
||||
// px = little-endian u32; R low 10 bits, A top 2.
|
||||
#[test]
|
||||
fn convert_packing() {
|
||||
let red = 0x0000_03FFu32.to_le_bytes(); // R=1023, rest 0
|
||||
let alpha = 0xC000_0000u32.to_le_bytes(); // A=3, rest 0
|
||||
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]); // opaque-less red
|
||||
assert_eq!(&dst[4..8], &[0, 0, 0, 255]); // opaque black
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_respects_src_stride_padding() {
|
||||
let (w, h, stride) = (2usize, 2usize, 12usize); // 4 bytes row padding
|
||||
let px = |v: u32| (v | 0xC000_0000).to_le_bytes(); // R=v, opaque
|
||||
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)); // zero dims
|
||||
assert!(!rgba1010102_to_rgba8888(&src, 4, 2, 2, &mut small)); // stride < w*4
|
||||
assert!(!rgba1010102_to_rgba8888(&src, 8, 2, 2, &mut small)); // dst too small
|
||||
}
|
||||
}
|
||||
26
native/crates/immich_core/src/lib.rs
Normal file
26
native/crates/immich_core/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! immich_core — shared Rust core for the immich server (napi) and mobile (dart:ffi).
|
||||
//!
|
||||
//! Pure logic only: no binding or platform deps live here. Each binding crate
|
||||
//! (`immich_core_ffi`, `immich_core_napi`) is a thin wrapper. Capabilities are
|
||||
//! cargo features (`image`, ...) so every binding opts into the same set.
|
||||
|
||||
#[cfg(feature = "image")]
|
||||
pub mod image;
|
||||
|
||||
#[cfg(feature = "thumbhash")]
|
||||
pub mod thumbhash;
|
||||
|
||||
/// Version of the native core. Smoke-test entrypoint exercised by every binding.
|
||||
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());
|
||||
}
|
||||
}
|
||||
503
native/crates/immich_core/src/thumbhash.rs
Normal file
503
native/crates/immich_core/src/thumbhash.rs
Normal file
@@ -0,0 +1,503 @@
|
||||
//! ThumbHash placeholder decoding (https://evanw.me/blog/thumbhash), replacing the
|
||||
//! per-platform ports (ThumbHash.java, Thumbhash.swift) with one implementation.
|
||||
//! Those two disagreed on output rounding (java rounds, swift truncates) and
|
||||
//! crashed on truncated hashes; this one is bounds-checked and rounds like the
|
||||
//! java/android behavior, so both platforms now render identical placeholders.
|
||||
//
|
||||
// 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,
|
||||
}
|
||||
|
||||
// Coefficient count for one channel — mirrors the reference iteration order.
|
||||
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
|
||||
}
|
||||
|
||||
// Parses and validates the header, including that every AC nibble the channels
|
||||
// will read actually exists — a truncated hash decodes to None instead of the
|
||||
// out-of-bounds crash of the old java/swift ports.
|
||||
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 rendered size comes from the *unclamped* aspect ratio (reference quirk);
|
||||
// a zero component would make a zero-sized image, so reject it as malformed.
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// Reads one channel's coefficients from the shared nibble stream
|
||||
// (boost saturation by 1.25x for P/Q is applied by the caller via `scale`).
|
||||
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
|
||||
}
|
||||
|
||||
/// Decoded placeholder size for `hash` — `None` if the hash is malformed.
|
||||
pub fn dims(hash: &[u8]) -> Option<(u32, u32)> {
|
||||
parse(hash).map(|hdr| (hdr.w as u32, hdr.h as u32))
|
||||
}
|
||||
|
||||
/// Render `hash` as RGBA8888 (not premultiplied) into the caller's `dst`, which
|
||||
/// must hold at least `w*h*4` bytes for the size reported by [`dims`]. Returns
|
||||
/// false without touching `dst` on a malformed hash or short buffer — same
|
||||
/// caller-owns-dst contract as the `image` module.
|
||||
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;
|
||||
|
||||
// DCT coefficients in f64 then narrowed, like the android port.
|
||||
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::*;
|
||||
|
||||
// Ground truth captured from the shipping Thumbhash.swift on 2026-07-10 (see
|
||||
// the PR notes): swift truncates the final float->u8 step while this port
|
||||
// rounds like the java one, so vectors match within 1 per channel.
|
||||
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> {
|
||||
// tiny standalone base64 (test-only, avoids a dep)
|
||||
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() {
|
||||
// Every prefix must either parse AND decode in-bounds, or be cleanly
|
||||
// rejected — the old java/swift ports crashed here. The transition must
|
||||
// be a single boundary: rejected below it, accepted from it on.
|
||||
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");
|
||||
// the header alone (alpha flag set, no AC data) can never be enough
|
||||
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));
|
||||
}
|
||||
}
|
||||
32
native/crates/immich_core_ffi/Cargo.toml
Normal file
32
native/crates/immich_core_ffi/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "immich_core_ffi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# native_toolchain_rust requires cdylib (the bundled lib) + staticlib (iOS). It
|
||||
# derives the artifact name from [package].name, so no [lib] name override here.
|
||||
# "lib" additionally lets tests/ link the crate as a normal rust dependency.
|
||||
[lib]
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
# features pinned explicitly so the cbindgen header + ffigen bindings always
|
||||
# match the exported symbols regardless of default-feature drift.
|
||||
[dependencies]
|
||||
immich_core = { path = "../immich_core", default-features = false, features = ["image", "thumbhash"] }
|
||||
# libc everywhere: returned buffers live on the libc heap so every consumer frees
|
||||
# them with plain free (dart malloc.free / kotlin NativeBuffer.free / swift free).
|
||||
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
|
||||
19
native/crates/immich_core_ffi/build.rs
Normal file
19
native/crates/immich_core_ffi/build.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
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();
|
||||
|
||||
// Hard-fail, not a warning: the header is committed and consumed by ffigen +
|
||||
// Swift, so a silent codegen failure would leave a stale header in the tree.
|
||||
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."
|
||||
79
native/crates/immich_core_ffi/include/immich_core.h
Normal file
79
native/crates/immich_core_ffi/include/immich_core.h
Normal file
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
// Generated by cbindgen — do not edit.
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/**
|
||||
* Native core version as a NUL-terminated UTF-8 string.
|
||||
* Free the result 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);
|
||||
|
||||
/**
|
||||
* Whether the EXIF `orientation` swaps width and height (the 90/270/transpose
|
||||
* family) — callers use it to size and report the rotated output dims.
|
||||
*/
|
||||
bool immich_core_orientation_swaps_dims(int32_t orientation);
|
||||
|
||||
/**
|
||||
* Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
|
||||
* `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
|
||||
* swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
|
||||
* inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
* bitmap lock + the dst allocation; this only fills dst.
|
||||
*
|
||||
* # Safety
|
||||
* `src` must be valid for reads of `src_len` bytes, `dst` for writes of `dst_len`,
|
||||
* and the two ranges 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);
|
||||
|
||||
/**
|
||||
* Convert an RGBA_1010102 image (`src`, `sh` rows of `src_stride` bytes) to
|
||||
* RGBA8888 in the caller's densely-packed `w*h*4` `dst`, matching Skia's
|
||||
* `Bitmap.copy(ARGB_8888)`. Returns false (a safe no-op) on null pointers or
|
||||
* inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
* bitmap lock + the dst allocation; this only fills dst.
|
||||
*
|
||||
* # Safety
|
||||
* `src` must be valid for reads of `src_len` bytes, `dst` for writes of `dst_len`,
|
||||
* and the two ranges 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);
|
||||
|
||||
/**
|
||||
* Decode a ThumbHash into a freshly malloc'd RGBA8888 buffer (not premultiplied
|
||||
* by alpha) and fill `out_info` with {width, height, rowBytes}. The caller owns
|
||||
* the buffer and releases it with `free`. Returns null on a malformed hash,
|
||||
* leaving `out_info` untouched.
|
||||
*
|
||||
* # Safety
|
||||
* `hash` must be valid for reads of `hash_len` bytes and `out_info` for writes
|
||||
* of three u32 values.
|
||||
*/
|
||||
uint8_t *immich_core_thumbhash_decode(const uint8_t *hash, uintptr_t hash_len, uint32_t *out_info);
|
||||
28
native/crates/immich_core_ffi/rust-toolchain.toml
Normal file
28
native/crates/immich_core_ffi/rust-toolchain.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
# The build hook (native_toolchain_rust) drives cargo via rustup and auto-installs
|
||||
# this toolchain + targets. Pin a version (never bare stable/beta) for reproducible
|
||||
# builds. Keep the channel in sync with mise.toml's rust pin.
|
||||
#
|
||||
# Full default target set from native_toolchain_rust: the hook validates the HOST
|
||||
# triple too (any dart/flutter command builds a host code asset), so linux + windows
|
||||
# must stay in for CI runners and contributor machines, not just the app targets.
|
||||
[toolchain]
|
||||
channel = "1.92.0"
|
||||
targets = [
|
||||
# Android
|
||||
"armv7-linux-androideabi",
|
||||
"aarch64-linux-android",
|
||||
"x86_64-linux-android",
|
||||
# iOS (device + simulator)
|
||||
"aarch64-apple-ios",
|
||||
"aarch64-apple-ios-sim",
|
||||
"x86_64-apple-ios",
|
||||
# Windows
|
||||
"aarch64-pc-windows-msvc",
|
||||
"x86_64-pc-windows-msvc",
|
||||
# Linux
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
# macOS
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-apple-darwin",
|
||||
]
|
||||
107
native/crates/immich_core_ffi/src/android/buffer.rs
Normal file
107
native/crates/immich_core_ffi/src/android/buffer.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! `NativeBuffer` — the libc-heap allocator Kotlin and Dart share. Ports of the
|
||||
//! former native_buffer.c, preserving its exact contracts.
|
||||
//!
|
||||
//! `jint` sizes/offsets sign-extend through `as usize` exactly like C's
|
||||
//! `int` → `size_t` conversion, so negative inputs stay huge-and-failing (malloc
|
||||
//! returns NULL) rather than becoming new behavior.
|
||||
//!
|
||||
//! The env-using calls run inside `EnvUnowned::with_env`, which upgrades the
|
||||
//! FFI-safe native-method env to a real `Env` and wraps the closure in
|
||||
//! `catch_unwind` — so a JNI error or a panic maps to the sentinel (0/null) and
|
||||
//! the caller falls back, never unwinding into the JVM. The pure allocator calls
|
||||
//! don't touch the env and contain no panicking code, so they run directly.
|
||||
|
||||
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: plain libc allocation; a negative size sign-extends huge and malloc
|
||||
// returns NULL, which flows back to Kotlin as 0 — same as the C it replaces.
|
||||
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: `address` came from allocate/realloc above (libc heap), or is 0,
|
||||
// which libc free accepts as a no-op.
|
||||
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: exact libc realloc semantics — NULL acts as malloc, OOM returns NULL
|
||||
// without freeing the original. The grown pointer is later freed by Dart, so it
|
||||
// must stay on the libc heap.
|
||||
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: `address`/`capacity` describe a live allocation from allocate or
|
||||
// realloc; the ByteBuffer only borrows it and Kotlin controls the lifetime.
|
||||
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);
|
||||
}
|
||||
// The caller owns the reference for the process lifetime (it backs a
|
||||
// never-released singleton), so hand out the raw ref and leak the wrapper
|
||||
// via into_raw — dropping the Global would delete the ref under Kotlin.
|
||||
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,
|
||||
}
|
||||
}
|
||||
210
native/crates/immich_core_ffi/src/android/image.rs
Normal file
210
native/crates/immich_core_ffi/src/android/image.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! `NativeImage` — the pixel exports Kotlin calls: the bitmap ops ported from the
|
||||
//! former native_image.c (lock the bitmap, run the shared `immich_core::image`
|
||||
//! math into a fresh libc buffer, hand it back) and the thumbhash decode.
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/// Locks the bitmap, runs `work` on its pixels into a fresh libc buffer of
|
||||
/// `dst_len`, and hands the buffer to Kotlin via `out_info` {width, height, rowBytes}.
|
||||
/// Returns 0 on any failure so the caller takes its existing Skia fallback.
|
||||
fn with_bitmap_into_buffer(
|
||||
env: &mut Env,
|
||||
bitmap: &JObject,
|
||||
out_info: &JIntArray,
|
||||
expected_format: i32,
|
||||
out_dims: impl FnOnce(&AndroidBitmapInfo) -> (i32, i32),
|
||||
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 dst_len = info.width as usize * info.height as usize * 4;
|
||||
// SAFETY: libc heap — this exact address is later freed via NativeBuffer.free
|
||||
// (Kotlin) or malloc.free (Dart).
|
||||
let dst = unsafe { libc::malloc(dst_len) } 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 malloc'd above and never escaped.
|
||||
unsafe { libc::free(dst as *mut libc::c_void) };
|
||||
return 0;
|
||||
}
|
||||
|
||||
let src_len = info.stride as usize * info.height as usize;
|
||||
// AssertUnwindSafe: catching here keeps the unlock + free below on the panic
|
||||
// path — otherwise an unwind would leak dst and leave the bitmap locked.
|
||||
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 was allocated with dst_len bytes above and never 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, dw * 4];
|
||||
if out_info.set_region(env, 0, &dims).is_err() || env.exception_check() {
|
||||
// Keep ownership here if Kotlin can never receive the address.
|
||||
// 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 as i32, info.width as i32)
|
||||
} else {
|
||||
(info.width as i32, info.height as i32)
|
||||
}
|
||||
},
|
||||
|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 as i32, info.height as i32),
|
||||
|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,
|
||||
}
|
||||
}
|
||||
32
native/crates/immich_core_ffi/src/android/jnigraphics.rs
Normal file
32
native/crates/immich_core_ffi/src/android/jnigraphics.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! The three stable-ABI bitmap calls from libjnigraphics.so (ships in every NDK
|
||||
//! sysroot). Hand-declared instead of pulling the ndk crate for three functions.
|
||||
|
||||
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;
|
||||
}
|
||||
19
native/crates/immich_core_ffi/src/android/log.rs
Normal file
19
native/crates/immich_core_ffi/src/android/log.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Logcat sink — `__android_log_write` from liblog (an NDK system library, like
|
||||
//! jnigraphics). Errors show up as `E/immich_core` in logcat.
|
||||
|
||||
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()) };
|
||||
}
|
||||
22
native/crates/immich_core_ffi/src/android/mod.rs
Normal file
22
native/crates/immich_core_ffi/src/android/mod.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! Android JNI exports — the `NativeBuffer` and `NativeImage` Kotlin objects load
|
||||
//! this library directly (`System.loadLibrary("immich_core_ffi")`), so the whole
|
||||
//! native layer is this one Rust cdylib. Split by the Kotlin object each group
|
||||
//! backs: [`buffer`] (NativeBuffer, the libc-heap allocator bridge) and [`image`]
|
||||
//! (NativeImage, the bitmap pixel ops), with [`jnigraphics`] holding the
|
||||
//! libjnigraphics declarations the image ops lock bitmaps through.
|
||||
//!
|
||||
//! Buffer memory MUST live on the libc heap: Dart frees these exact addresses via
|
||||
//! `package:ffi` `malloc.free` (process-global libc `free`), and Kotlin grows them
|
||||
//! with `realloc`. Rust's own allocator is never allowed to touch them.
|
||||
//!
|
||||
//! Panics never unwind into the JVM: the env-using methods run their body inside
|
||||
//! `EnvUnowned::with_env`, which wraps it in `catch_unwind` and maps a panic (or a
|
||||
//! JNI error) to the sentinel return; the pure allocator methods have no panicking
|
||||
//! code at all.
|
||||
|
||||
mod buffer;
|
||||
mod image;
|
||||
mod jnigraphics;
|
||||
mod log;
|
||||
|
||||
pub(crate) use log::log_error;
|
||||
106
native/crates/immich_core_ffi/src/capi/image.rs
Normal file
106
native/crates/immich_core_ffi/src/capi/image.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! C-ABI image ops. The platform side owns the bitmap lock + the `dst` allocation
|
||||
//! and calls these to fill `dst` from `src`; both are caller-owned buffers.
|
||||
|
||||
/// Shared boundary for the caller-owned-`dst` pixel ops: null-check both pointers,
|
||||
/// view them as slices, and run `op` under catch_unwind — a panic mid-write only
|
||||
/// leaves `dst` partially filled, and the `false` return tells the caller to
|
||||
/// discard it. Nothing outside `src_len`/`dst_len` is touched.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes, `dst` for writes of `dst_len`,
|
||||
/// and the two ranges 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: caller guarantees `src` is valid for reads of `src_len` bytes (see # Safety).
|
||||
let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) };
|
||||
// SAFETY: caller guarantees `dst` is valid for writes of `dst_len` bytes (see # Safety).
|
||||
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
|
||||
// AssertUnwindSafe: the closure writes through `&mut dst_slice`, which isn't
|
||||
// UnwindSafe, but a partial write is discarded on the `false` path.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| op(src_slice, dst_slice)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether the EXIF `orientation` swaps width and height (the 90/270/transpose
|
||||
/// family) — callers use it to size and report the rotated output dims.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn immich_core_orientation_swaps_dims(orientation: i32) -> bool {
|
||||
super::guard(false, || immich_core::image::swaps_dims(orientation))
|
||||
}
|
||||
|
||||
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
|
||||
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
|
||||
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes, `dst` for writes of `dst_len`,
|
||||
/// and the two ranges 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: pointers/lengths forwarded verbatim to fill_dst (see # Safety).
|
||||
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,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an RGBA_1010102 image (`src`, `sh` rows of `src_stride` bytes) to
|
||||
/// RGBA8888 in the caller's densely-packed `w*h*4` `dst`, matching Skia's
|
||||
/// `Bitmap.copy(ARGB_8888)`. Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes, `dst` for writes of `dst_len`,
|
||||
/// and the two ranges 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: pointers/lengths forwarded verbatim to fill_dst (see # Safety).
|
||||
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,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
75
native/crates/immich_core_ffi/src/capi/mod.rs
Normal file
75
native/crates/immich_core_ffi/src/capi/mod.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//! The portable C ABI — the `extern "C"` surface cbindgen turns into
|
||||
//! `include/immich_core.h`, consumed by Dart (`@Native`) and Swift (the header).
|
||||
//! [`image`] holds the pixel ops; this file holds the version/string lifecycle and
|
||||
//! the shared panic guard.
|
||||
|
||||
pub mod image;
|
||||
pub mod thumbhash;
|
||||
|
||||
use std::ffi::{c_char, CString};
|
||||
use std::ptr;
|
||||
|
||||
/// Native core version as a NUL-terminated UTF-8 string.
|
||||
/// Free the result 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: `ptr` came from this library's `CString::into_raw` (see # Safety).
|
||||
let s = unsafe { CString::from_raw(ptr) };
|
||||
drop(s);
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `f` at the FFI boundary, turning a panic into `sentinel` rather than
|
||||
/// unwinding across `extern "C"` into the host. Guards panics only — a bad `len`
|
||||
/// or a double/foreign free is caller-contract UB that stays the caller's
|
||||
/// `# Safety` obligation, not something this can catch.
|
||||
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 non-null NUL-terminated string from 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 exactly once.
|
||||
unsafe { immich_core_free_string(p) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_null_is_noop() {
|
||||
// SAFETY: free_string explicitly accepts null.
|
||||
unsafe { immich_core_free_string(ptr::null_mut()) };
|
||||
}
|
||||
}
|
||||
69
native/crates/immich_core_ffi/src/capi/thumbhash.rs
Normal file
69
native/crates/immich_core_ffi/src/capi/thumbhash.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! C-ABI thumbhash decode. One call: parse, malloc, fill. Every consumer (dart,
|
||||
//! swift, and kotlin via the JNI wrapper) wants an allocated RGBA buffer, so
|
||||
//! unlike the image ops there is no caller-owned dst here — the buffer comes back
|
||||
//! on the libc heap and the caller releases it with plain `free` (dart:
|
||||
//! `malloc.free`, kotlin: `NativeBuffer.free`).
|
||||
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
|
||||
/// Decodes `hash` into a fresh libc allocation. Shared by the C export below and
|
||||
/// the android JNI wrapper. Returns the buffer with its (width, height), or None
|
||||
/// on a malformed hash — never leaking the allocation on any failure path.
|
||||
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: libc heap — this exact address is later freed by the consumer via
|
||||
// free()/malloc.free/NativeBuffer.free.
|
||||
let dst = unsafe { libc::malloc(len) } as *mut u8;
|
||||
if dst.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: dst was allocated with len bytes above and never escaped.
|
||||
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, len) };
|
||||
// AssertUnwindSafe: a panic mid-fill only leaves dst partially written, and
|
||||
// dst is freed right here on that path.
|
||||
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))
|
||||
}
|
||||
|
||||
/// Decode a ThumbHash into a freshly malloc'd RGBA8888 buffer (not premultiplied
|
||||
/// by alpha) and fill `out_info` with {width, height, rowBytes}. The caller owns
|
||||
/// the buffer and releases it with `free`. Returns null on a malformed hash,
|
||||
/// leaving `out_info` untouched.
|
||||
///
|
||||
/// # Safety
|
||||
/// `hash` must be valid for reads of `hash_len` bytes and `out_info` for writes
|
||||
/// of three u32 values.
|
||||
#[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: caller guarantees `hash` is valid for reads of `hash_len` bytes (see # Safety).
|
||||
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: caller guarantees `out_info` is valid for three u32 writes (see # Safety).
|
||||
unsafe {
|
||||
*out_info = w;
|
||||
*out_info.add(1) = h;
|
||||
*out_info.add(2) = w * 4;
|
||||
}
|
||||
dst
|
||||
}
|
||||
_ => std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
46
native/crates/immich_core_ffi/src/ios/log.rs
Normal file
46
native/crates/immich_core_ffi/src/ios/log.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Unified-log sink — NSLog through hand-declared Foundation/CoreFoundation
|
||||
//! externs (stable C ABI, no crate deps; same pattern as the android jnigraphics
|
||||
//! declarations). Errors show up in Xcode's console and Console.app.
|
||||
|
||||
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: the format literal and message are live NUL-terminated strings; the
|
||||
// "%@" format takes exactly one object argument, so no format injection from
|
||||
// the message is possible. Both CFStrings are released after the call.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
native/crates/immich_core_ffi/src/ios/mod.rs
Normal file
9
native/crates/immich_core_ffi/src/ios/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! iOS platform integration. Swift needs no binding layer here — it calls the
|
||||
//! portable C ABI in [`crate::capi`] directly (via the cbindgen header and the
|
||||
//! app's `NativeCore.swift` loader), which is why this module is small next to
|
||||
//! [`crate::android`]: Kotlin's VM needs JNI shims, Swift does not. What lives
|
||||
//! here is the code that must talk *to* the platform, like the unified-log sink.
|
||||
|
||||
mod log;
|
||||
|
||||
pub(crate) use log::log_error;
|
||||
32
native/crates/immich_core_ffi/src/lib.rs
Normal file
32
native/crates/immich_core_ffi/src/lib.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! FFI wrapper around `immich_core` for the mobile app. One cdylib, two ABI
|
||||
//! surfaces: [`capi`] is the portable `extern "C"` layer (Dart `@Native` + the
|
||||
//! Swift header cbindgen emits into `include/immich_core.h`); [`android`] is the
|
||||
//! JNI layer Kotlin loads via `System.loadLibrary` — [`ios`] stays small because
|
||||
//! Swift calls [`capi`] directly. [`runtime`] is the shared tokio runtime for
|
||||
//! async work; [`log`] makes boundary failures visible in logcat / Console.app.
|
||||
//!
|
||||
//! C strings returned here are heap-allocated; the caller frees them with
|
||||
//! `immich_core_free_string`.
|
||||
#![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;
|
||||
|
||||
// Re-export the C-ABI surface at the crate root so Rust consumers (the c_abi
|
||||
// integration test) reach it as `immich_core_ffi::immich_core_*`; the exported
|
||||
// symbols themselves are name-based (`#[no_mangle]`), independent of this path.
|
||||
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};
|
||||
38
native/crates/immich_core_ffi/src/log.rs
Normal file
38
native/crates/immich_core_ffi/src/log.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! Failure visibility. Every FFI boundary converts a panic into a sentinel the
|
||||
//! caller silently falls back on — right for the caller, but it would bury the
|
||||
//! failure. [`ensure_panic_hook`] installs a process-wide hook (once) that writes
|
||||
//! the panic message and location to the platform log first: logcat on Android,
|
||||
//! the unified log (Console.app) on iOS, stderr elsewhere.
|
||||
|
||||
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);
|
||||
}));
|
||||
});
|
||||
}
|
||||
46
native/crates/immich_core_ffi/src/runtime.rs
Normal file
46
native/crates/immich_core_ffi/src/runtime.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! The shared tokio runtime for every async task the core ever runs. One static
|
||||
//! multi-thread runtime, created lazily on first use and reused for the process
|
||||
//! lifetime — FFI entry points must never build per-call or scoped runtimes.
|
||||
//! When the first async capability gets an FFI surface, this can graduate to an
|
||||
//! explicit init/shutdown lifecycle.
|
||||
|
||||
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}"))
|
||||
});
|
||||
|
||||
/// The process-wide runtime. Spawn long-lived work with `runtime().spawn(...)`;
|
||||
/// FFI callers that must wait use `runtime().block_on(...)` off the UI thread.
|
||||
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);
|
||||
|
||||
// spawned work runs on the same shared runtime and can be awaited again
|
||||
let handle = runtime().spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
7
|
||||
});
|
||||
assert_eq!(runtime().block_on(handle).unwrap(), 7);
|
||||
}
|
||||
}
|
||||
226
native/crates/immich_core_ffi/tests/c_abi.rs
Normal file
226
native/crates/immich_core_ffi/tests/c_abi.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! Exercises the extern "C" surface exactly as a foreign caller (dart/swift/kotlin)
|
||||
//! would: raw pointers in, sentinel returns on bad input, caller-owned buffers. The
|
||||
//! android JNI module is excluded — those functions need a live JVM and are covered
|
||||
//! by mobile/integration_test/native_jni_test.dart on device.
|
||||
// unsafe-without-SAFETY-comments allowed here: every call simulates a raw foreign
|
||||
// caller, including deliberately invalid inputs the guards must reject.
|
||||
#![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,
|
||||
};
|
||||
|
||||
// "1QcSHQRnh493V4dIh4eXh1h4kJUI" decoded — the classic 23x32 opaque sample.
|
||||
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() {
|
||||
// 2x1: red, green -> 180 -> green, red
|
||||
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() {
|
||||
// 2x1 -> 90 -> 1x2: first output row is the right-hand src pixel
|
||||
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 {
|
||||
// null src / null dst
|
||||
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
|
||||
));
|
||||
// src_len shorter than stride*h, dst_len shorter than w*h*4
|
||||
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() {
|
||||
// (1023 -> 255) and the round-vs-shift discriminators (179 -> 45, 111 -> 28).
|
||||
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) };
|
||||
// opaque hash: every pixel fully opaque, image not a single flat color
|
||||
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()
|
||||
);
|
||||
}
|
||||
// failed decodes never touched the out params
|
||||
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();
|
||||
}
|
||||
12
native/crates/immich_core_napi/src/lib.rs
Normal file
12
native/crates/immich_core_napi/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! napi-rs binding for immich_core (node server).
|
||||
//!
|
||||
//! Built as a cdylib loaded as a `.node` addon — the same shape as the server's
|
||||
//! existing native deps (sharp, bcrypt).
|
||||
|
||||
use napi_derive::napi;
|
||||
|
||||
/// Native core version. JS: `core.coreVersion()`.
|
||||
#[napi]
|
||||
pub fn core_version() -> String {
|
||||
immich_core::core_version().to_owned()
|
||||
}
|
||||
33
native/immich_native_core/.gitignore
vendored
Normal file
33
native/immich_native_core/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
43
native/immich_native_core/README.md
Normal file
43
native/immich_native_core/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# immich_native_core (Flutter package)
|
||||
|
||||
dart:ffi bindings to the `immich_native_core` Rust core. The native code is **built
|
||||
from source on every app build** via a Dart build hook (Flutter native assets) — no
|
||||
prebuilt binaries, no `DynamicLibrary`, no platform plugin glue.
|
||||
|
||||
## Use it from immich/mobile
|
||||
|
||||
```yaml
|
||||
# mobile/pubspec.yaml
|
||||
dependencies:
|
||||
immich_native_core:
|
||||
path: ../native/immich_native_core
|
||||
```
|
||||
|
||||
`dart pub get`. The dart surface is exactly the ffigen output of the C header —
|
||||
the current capabilities (EXIF rotate, 10-bit convert, thumbhash decode) are called
|
||||
by the platform decode pipelines, not by dart, so there are no hand-written dart
|
||||
wrappers. Add one only when a dart feature actually consumes a function.
|
||||
|
||||
No app-level Gradle/Podfile edits. `hook/build.dart` compiles the Rust crate and
|
||||
Flutter bundles it as a code asset; the `@Native` bindings resolve against it.
|
||||
**Requirement:** every machine that builds the app needs [rustup](https://rustup.rs)
|
||||
— the hook auto-installs the pinned toolchain + targets from the crate's
|
||||
`rust-toolchain.toml`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `hook/build.dart` — builds `../crates/immich_core_ffi` via `native_toolchain_rust`.
|
||||
- `lib/immich_native_core.dart` — barrel; re-exports the generated bindings.
|
||||
- `lib/src/ffi/bindings.g.dart` — ffigen `@Native` output (committed; do not edit).
|
||||
- `ffigen.yaml` — ffi-native mode; asset-id must match the hook's `assetName`.
|
||||
- `test/` — host FFI roundtrip (`flutter test`); device runs via `mobile/integration_test`.
|
||||
|
||||
## ⚠ iOS App Extensions
|
||||
|
||||
Code assets are bundled into the app's **Runner** target. immich ships a Share
|
||||
Extension and a Widget Extension — if the core is ever called from one of those,
|
||||
verify the symbols resolve there (same family as the embed-into-Runner-only gotcha).
|
||||
Not an issue while only the main app calls it.
|
||||
|
||||
The Rust workspace, the codegen/build/test commands, and the "add a function" loop
|
||||
live in [`../README.md`](../README.md).
|
||||
4
native/immich_native_core/analysis_options.yaml
Normal file
4
native/immich_native_core/analysis_options.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
20
native/immich_native_core/ffigen.yaml
Normal file
20
native/immich_native_core/ffigen.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Regenerate: `mise run codegen` (cbindgen header -> ffigen @Native bindings).
|
||||
# ffi-native mode emits top-level @Native externals + a library @DefaultAsset
|
||||
# pointing at the code asset hook/build.dart produces — no DynamicLibrary loader.
|
||||
# asset-id MUST equal the generated file's package URI (and the hook's assetName).
|
||||
name: ImmichNativeCoreBindings
|
||||
ffi-native:
|
||||
asset-id: 'package:immich_native_core/src/ffi/bindings.g.dart'
|
||||
description: 'FFI bindings to immich_native_core — generated, do not edit.'
|
||||
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
|
||||
71
native/immich_native_core/hook/build.dart
Normal file
71
native/immich_native_core/hook/build.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:code_assets/code_assets.dart';
|
||||
import 'package:hooks/hooks.dart';
|
||||
import 'package:native_toolchain_rust/native_toolchain_rust.dart';
|
||||
|
||||
// Builds crates/immich_core_ffi from source on every app build and bundles it as
|
||||
// a code asset. assetName must match the ffigen output (its package URI is the
|
||||
// @Native DefaultAsset id). The crate is a sibling, so point cratePath at it.
|
||||
void main(List<String> args) async {
|
||||
await build(args, (input, output) async {
|
||||
await _ensureRustTarget(input);
|
||||
await RustBuilder(
|
||||
assetName: 'src/ffi/bindings.g.dart',
|
||||
cratePath: '../crates/immich_core_ffi',
|
||||
// Android requires 16 KB-aligned load segments for Play (apps targeting
|
||||
// Android 15+); the NDK linker still defaults to 4 KB, so force the page
|
||||
// size for the .so. iOS/macOS/host use their own alignment and must not
|
||||
// get this ELF-only flag. Set via env (not .cargo/config.toml) because the
|
||||
// hook runs cargo from Flutter's cwd, where a crate-local config wouldn't
|
||||
// be discovered.
|
||||
extraCargoEnvironmentVariables: {
|
||||
if (input.config.code.targetOS == OS.android)
|
||||
'RUSTFLAGS': '-C link-arg=-Wl,-z,max-page-size=16384',
|
||||
},
|
||||
).run(input: input, output: output);
|
||||
});
|
||||
}
|
||||
|
||||
// rustup only auto-installs the rust-toolchain.toml targets when that file drives
|
||||
// toolchain selection; with RUSTUP_TOOLCHAIN exported (mise does on CI) the file is
|
||||
// bypassed and cross targets never install. Add the build target explicitly — a
|
||||
// fast no-op when it's already present.
|
||||
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.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/')
|
||||
.toFilePath();
|
||||
// best effort — if rustup itself is broken the build below reports it properly
|
||||
await Process.run('rustup', [
|
||||
'target',
|
||||
'add',
|
||||
triple,
|
||||
], workingDirectory: crate);
|
||||
}
|
||||
8
native/immich_native_core/lib/immich_native_core.dart
Normal file
8
native/immich_native_core/lib/immich_native_core.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
/// dart:ffi bindings to the immich_native_core Rust core, built from source and
|
||||
/// bundled via the Dart build hook. The dart surface is exactly the ffigen output
|
||||
/// of the C header — the production callers of the current capabilities are the
|
||||
/// platform decode pipelines (Kotlin/JNI, Swift), so there are no hand-written
|
||||
/// dart wrappers; add one only when a dart feature actually consumes a function.
|
||||
library;
|
||||
|
||||
export 'src/ffi/bindings.g.dart';
|
||||
108
native/immich_native_core/lib/src/ffi/bindings.g.dart
Normal file
108
native/immich_native_core/lib/src/ffi/bindings.g.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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;
|
||||
|
||||
/// Native core version as a NUL-terminated UTF-8 string.
|
||||
/// Free the result 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);
|
||||
|
||||
/// Whether the EXIF `orientation` swaps width and height (the 90/270/transpose
|
||||
/// family) — callers use it to size and report the rotated output dims.
|
||||
@ffi.Native<ffi.Bool Function(ffi.Int32)>()
|
||||
external bool immich_core_orientation_swaps_dims(int orientation);
|
||||
|
||||
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
|
||||
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
|
||||
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes, `dst` for writes of `dst_len`,
|
||||
/// and the two ranges 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,
|
||||
);
|
||||
|
||||
/// Convert an RGBA_1010102 image (`src`, `sh` rows of `src_stride` bytes) to
|
||||
/// RGBA8888 in the caller's densely-packed `w*h*4` `dst`, matching Skia's
|
||||
/// `Bitmap.copy(ARGB_8888)`. Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes, `dst` for writes of `dst_len`,
|
||||
/// and the two ranges 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,
|
||||
);
|
||||
|
||||
/// Decode a ThumbHash into a freshly malloc'd RGBA8888 buffer (not premultiplied
|
||||
/// by alpha) and fill `out_info` with {width, height, rowBytes}. The caller owns
|
||||
/// the buffer and releases it with `free`. Returns null on a malformed hash,
|
||||
/// leaving `out_info` untouched.
|
||||
///
|
||||
/// # Safety
|
||||
/// `hash` must be valid for reads of `hash_len` bytes and `out_info` for writes
|
||||
/// of three u32 values.
|
||||
@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,
|
||||
);
|
||||
26
native/immich_native_core/pubspec.yaml
Normal file
26
native/immich_native_core/pubspec.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: immich_native_core
|
||||
description: "dart:ffi bindings to the immich_native_core Rust core, built from source via Dart build hooks."
|
||||
version: 0.1.0
|
||||
homepage: https://github.com/immich-app/immich
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
# Not a platform plugin: the native lib is built + bundled by hook/build.dart as a
|
||||
# code asset (Flutter native assets), so there is no ffiPlugin / android / ios dir.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
# build-hook deps — run at build time to compile the Rust crate (need rustup).
|
||||
code_assets: ^1.2.1
|
||||
hooks: ^2.0.2
|
||||
native_toolchain_rust: ^1.0.4
|
||||
|
||||
dev_dependencies:
|
||||
ffi: ^2.2.0 # tests only — the lib surface is the generated bindings (dart:ffi)
|
||||
ffigen: 20.1.1 # pinned exact — a caret bump can re-emit bindings
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
142
native/immich_native_core/test/native_core_test.dart
Normal file
142
native/immich_native_core/test/native_core_test.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
// Host FFI roundtrip — `flutter test` builds the hook for the host platform and
|
||||
// resolves the @Native symbols, no device needed. Calls the generated bindings
|
||||
// directly (the package's actual surface); device runs: mobile/integration_test.
|
||||
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);
|
||||
|
||||
// malloc src+dst, run the native call, return the dst bytes (or null if it declined).
|
||||
Uint8List? _withBuffers(Uint8List src, int dstLen, _ImageFn call) {
|
||||
final srcPtr = malloc<Uint8>(src.length);
|
||||
final dstPtr = malloc<Uint8>(dstLen);
|
||||
try {
|
||||
srcPtr.asTypedList(src.length).setAll(0, src);
|
||||
if (!call(srcPtr, dstLen, dstPtr)) {
|
||||
return null;
|
||||
}
|
||||
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
|
||||
} finally {
|
||||
malloc.free(srcPtr);
|
||||
malloc.free(dstPtr);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('core loads: version roundtrips through the C string contract', () {
|
||||
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('orientation swaps dims exactly for the 90/270/transpose family', () {
|
||||
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('exif rotate: 180 reverses pixels, 90 swaps dims', () {
|
||||
// 2x1 image: red, green (RGBA).
|
||||
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]); // green, red
|
||||
|
||||
final r90 = _withBuffers(
|
||||
src,
|
||||
8,
|
||||
(s, len, d) =>
|
||||
immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 6, d, len),
|
||||
);
|
||||
expect(r90, isNotNull); // 90 -> 1x2, same byte count
|
||||
});
|
||||
|
||||
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('10-bit convert matches the on-device Skia ground truth', () {
|
||||
// 179->45 and 111->28 pin round(v*255/1023); a plain >>2 would give 44/27.
|
||||
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('thumbhash decodes via the core into a malloc buffer', () {
|
||||
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);
|
||||
|
||||
// malformed hash: null return, info untouched
|
||||
expect(immich_core_thumbhash_decode(hashPtr, 4, info), equals(nullptr));
|
||||
} finally {
|
||||
malloc.free(hashPtr);
|
||||
malloc.free(info);
|
||||
}
|
||||
});
|
||||
}
|
||||
68
native/mise.toml
Normal file
68
native/mise.toml
Normal file
@@ -0,0 +1,68 @@
|
||||
[tools]
|
||||
rust = "1.92.0" # 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"
|
||||
|
||||
# Regen the committed cbindgen header + ffigen @Native bindings.
|
||||
[tasks."codegen:ffigen"]
|
||||
alias = "codegen"
|
||||
description = "Generate the C header (cbindgen) + Dart @Native bindings (ffigen)"
|
||||
sources = [
|
||||
"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 -p immich_core_ffi",
|
||||
"cd immich_native_core && dart run ffigen --config ffigen.yaml && dart format lib/src/ffi/bindings.g.dart",
|
||||
]
|
||||
|
||||
# Host FFI roundtrip through the real build hook — no device. Builds the Rust crate
|
||||
# via rustup + resolves the @Native code asset.
|
||||
[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 target/debug/libimmich_core_ffi.dylib"
|
||||
|
||||
[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"]
|
||||
17
native/scripts/build-linux.sh
Executable file
17
native/scripts/build-linux.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-build the napi addon for Linux server (x86_64 + aarch64) via zigbuild
|
||||
# (no Docker) and stage as .node under dist/server/<target>/.
|
||||
# In CI you'd build these natively per-arch instead; this is local convenience.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
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"
|
||||
31
native/smoke/dart_smoke.dart
Normal file
31
native/smoke/dart_smoke.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
// Mobile-side roundtrip: open the dart:ffi cdylib and call into the shared core.
|
||||
// Standalone script (no package:ffi dep) — reads the returned C string by hand.
|
||||
//
|
||||
// dart run smoke/dart_smoke.dart target/debug/libimmich_core_ffi.dylib
|
||||
|
||||
import 'dart:ffi';
|
||||
|
||||
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 libPath = args.isNotEmpty ? args.first : 'target/debug/libimmich_core_ffi.dylib';
|
||||
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');
|
||||
}
|
||||
13
native/smoke/node_smoke.mjs
Normal file
13
native/smoke/node_smoke.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
// Server-side roundtrip: load the napi addon and call into the shared core.
|
||||
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