Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
621504fcda | ||
|
|
919a4fd9ef | ||
|
|
2ed9207c3f | ||
|
|
c48394aa6e | ||
|
|
86c8b9e04d | ||
|
|
1190d0cc80 | ||
|
|
9669f7cc6e | ||
|
|
a5b2dcb3c2 | ||
|
|
f94fbfe14e | ||
|
|
70ef2884f0 | ||
|
|
918d66fbdb | ||
|
|
5d1167a36c | ||
|
|
e3bf54e2ba |
@@ -14,7 +14,7 @@ dependencies = [
|
|||||||
"onnx>=1.22.0",
|
"onnx>=1.22.0",
|
||||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||||
"orjson>=3.9.5",
|
"orjson>=3.9.5",
|
||||||
"pillow>=12.2,<13",
|
"pillow>=13,<14",
|
||||||
"pydantic>=2.0.0,<3",
|
"pydantic>=2.0.0,<3",
|
||||||
"pydantic-settings>=2.5.2,<3",
|
"pydantic-settings>=2.5.2,<3",
|
||||||
"python-multipart>=0.0.6,<1.0",
|
"python-multipart>=0.0.6,<1.0",
|
||||||
@@ -64,7 +64,7 @@ compile-bytecode = true
|
|||||||
override-dependencies = ["opencv-python ; sys_platform == 'never'"] # only install the headless version
|
override-dependencies = ["opencv-python ; sys_platform == 'never'"] # only install the headless version
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "8bf3fd1b44b0c8608f52fb6e50ddff52826576d8" }
|
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "a1eeff2c2118d76067efecf4cc4630998eac519a" }
|
||||||
onnxruntime-gpu = { index = "onnxruntime-cuda-12" }
|
onnxruntime-gpu = { index = "onnxruntime-cuda-12" }
|
||||||
openvino = { index = "openvino-nightly" }
|
openvino = { index = "openvino-nightly" }
|
||||||
|
|
||||||
|
|||||||
Generated
+719
-531
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
@@ -79,11 +79,6 @@ android {
|
|||||||
}
|
}
|
||||||
namespace 'app.alextran.immich'
|
namespace 'app.alextran.immich'
|
||||||
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
path "CMakeLists.txt"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
#include <jni.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
JNIEXPORT jlong JNICALL
|
|
||||||
Java_app_alextran_immich_NativeBuffer_allocate(
|
|
||||||
JNIEnv *env, jclass clazz, jint size) {
|
|
||||||
void *ptr = malloc(size);
|
|
||||||
return (jlong) ptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
JNIEXPORT void JNICALL
|
|
||||||
Java_app_alextran_immich_NativeBuffer_free(
|
|
||||||
JNIEnv *env, jclass clazz, jlong address) {
|
|
||||||
free((void *) address);
|
|
||||||
}
|
|
||||||
|
|
||||||
JNIEXPORT jlong JNICALL
|
|
||||||
Java_app_alextran_immich_NativeBuffer_realloc(
|
|
||||||
JNIEnv *env, jclass clazz, jlong address, jint size) {
|
|
||||||
void *ptr = realloc((void *) address, size);
|
|
||||||
return (jlong) ptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
JNIEXPORT jobject JNICALL
|
|
||||||
Java_app_alextran_immich_NativeBuffer_wrap(
|
|
||||||
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
|
||||||
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
JNIEXPORT void JNICALL
|
|
||||||
Java_app_alextran_immich_NativeBuffer_copy(
|
|
||||||
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
|
|
||||||
void *src = (*env)->GetDirectBufferAddress(env, buffer);
|
|
||||||
if (src != NULL) {
|
|
||||||
memcpy((void *) destAddress, (char *) src + offset, length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a JNI global reference to the given object and returns its address.
|
|
||||||
* The caller is responsible for deleting the global reference when it's no longer needed.
|
|
||||||
*/
|
|
||||||
JNIEXPORT jlong JNICALL
|
|
||||||
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
|
|
||||||
if (obj == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
jobject globalRef = (*env)->NewGlobalRef(env, obj);
|
|
||||||
return (jlong) globalRef;
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
#include <jni.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <android/bitmap.h>
|
|
||||||
|
|
||||||
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
|
|
||||||
#define TILE 32
|
|
||||||
|
|
||||||
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
|
|
||||||
enum {
|
|
||||||
ORIENTATION_FLIP_HORIZONTAL = 2,
|
|
||||||
ORIENTATION_ROTATE_180 = 3,
|
|
||||||
ORIENTATION_FLIP_VERTICAL = 4,
|
|
||||||
ORIENTATION_TRANSPOSE = 5,
|
|
||||||
ORIENTATION_ROTATE_90 = 6,
|
|
||||||
ORIENTATION_TRANSVERSE = 7,
|
|
||||||
ORIENTATION_ROTATE_270 = 8,
|
|
||||||
};
|
|
||||||
|
|
||||||
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
|
|
||||||
static int swaps_dims(int o) {
|
|
||||||
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
|
|
||||||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
|
|
||||||
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
|
|
||||||
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
|
|
||||||
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
|
|
||||||
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
|
|
||||||
switch (o) {
|
|
||||||
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
|
|
||||||
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
|
|
||||||
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
|
|
||||||
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
|
|
||||||
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
|
|
||||||
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
|
|
||||||
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
|
|
||||||
default: *base = 0; *stepX = 1; *stepY = dw; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
|
|
||||||
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
|
|
||||||
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
|
|
||||||
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
|
|
||||||
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
|
|
||||||
for (int ty = 0; ty < sh; ty += TILE) {
|
|
||||||
int yEnd = ty + TILE < sh ? ty + TILE : sh;
|
|
||||||
for (int tx = 0; tx < sw; tx += TILE) {
|
|
||||||
int xEnd = tx + TILE < sw ? tx + TILE : sw;
|
|
||||||
for (int sy = ty; sy < yEnd; sy++) {
|
|
||||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
|
|
||||||
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
|
|
||||||
for (int sx = tx; sx < xEnd; sx++) {
|
|
||||||
dst[idx] = srcRow[sx];
|
|
||||||
idx += stepX;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
|
|
||||||
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
|
|
||||||
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
|
|
||||||
JNIEXPORT jlong JNICALL
|
|
||||||
Java_app_alextran_immich_NativeImage_rotate(
|
|
||||||
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
|
|
||||||
AndroidBitmapInfo info;
|
|
||||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int sw = (int) info.width;
|
|
||||||
int sh = (int) info.height;
|
|
||||||
int dw = swaps_dims(orientation) ? sh : sw;
|
|
||||||
int dh = swaps_dims(orientation) ? sw : sh;
|
|
||||||
|
|
||||||
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
|
|
||||||
if (dst == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void *srcPixels = NULL;
|
|
||||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
|
||||||
free(dst);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int64_t base, stepX, stepY;
|
|
||||||
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
|
|
||||||
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
|
|
||||||
|
|
||||||
AndroidBitmap_unlockPixels(env, bitmap);
|
|
||||||
|
|
||||||
jint dims[3] = {dw, dh, dw * 4};
|
|
||||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
|
||||||
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
|
|
||||||
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
|
|
||||||
if ((*env)->ExceptionCheck(env)) {
|
|
||||||
free(dst);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return (jlong) dst;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
|
|
||||||
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
|
|
||||||
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
|
|
||||||
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
|
|
||||||
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
|
|
||||||
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
|
|
||||||
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
|
|
||||||
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
|
|
||||||
for (int y = 0; y < h; y++) {
|
|
||||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
|
|
||||||
uint32_t *dstRow = dst + (size_t) y * w;
|
|
||||||
for (int x = 0; x < w; x++) {
|
|
||||||
uint32_t px = srcRow[x];
|
|
||||||
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
|
|
||||||
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
|
|
||||||
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
|
|
||||||
uint32_t a = ((px >> 30) & 0x3) * 85u;
|
|
||||||
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
|
|
||||||
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
|
|
||||||
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
|
|
||||||
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
|
|
||||||
JNIEXPORT jlong JNICALL
|
|
||||||
Java_app_alextran_immich_NativeImage_convert1010102(
|
|
||||||
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
|
|
||||||
AndroidBitmapInfo info;
|
|
||||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int w = (int) info.width;
|
|
||||||
int h = (int) info.height;
|
|
||||||
|
|
||||||
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
|
|
||||||
if (dst == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void *srcPixels = NULL;
|
|
||||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
|
||||||
free(dst);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
|
|
||||||
|
|
||||||
AndroidBitmap_unlockPixels(env, bitmap);
|
|
||||||
|
|
||||||
jint dims[3] = {w, h, w * 4};
|
|
||||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
|
||||||
if ((*env)->ExceptionCheck(env)) {
|
|
||||||
free(dst);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return (jlong) dst;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024
|
|||||||
|
|
||||||
object NativeBuffer {
|
object NativeBuffer {
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("native_buffer")
|
System.loadLibrary("native_core_ffi")
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
object NativeImage {
|
object NativeImage {
|
||||||
init {
|
init {
|
||||||
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
|
System.loadLibrary("native_core_ffi")
|
||||||
System.loadLibrary("native_buffer")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,4 +26,24 @@ object NativeImage {
|
|||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like [convert1010102], but decodes the JPEG XL image of [length] bytes at [address], stopped by
|
||||||
|
* [cancel], downscaled to cover [targetWidth] x [targetHeight] unless either is 0 and to fit
|
||||||
|
* within [maxPixelSize].
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
external fun decodeJxl(
|
||||||
|
address: Long,
|
||||||
|
length: Int,
|
||||||
|
targetWidth: Int,
|
||||||
|
targetHeight: Int,
|
||||||
|
maxPixelSize: Int,
|
||||||
|
cancelled: ByteBuffer,
|
||||||
|
outInfo: IntArray
|
||||||
|
): Long
|
||||||
|
|
||||||
|
/** Stops the [decodeJxl] given this one-byte direct buffer. */
|
||||||
|
@JvmStatic
|
||||||
|
external fun cancel(cancelled: ByteBuffer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.exifinterface.media.ExifInterface
|
|||||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import app.alextran.immich.NativeByteBuffer
|
import app.alextran.immich.NativeByteBuffer
|
||||||
|
import app.alextran.immich.NativeImage
|
||||||
import app.alextran.immich.core.HttpClientManager
|
import app.alextran.immich.core.HttpClientManager
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
@@ -29,6 +30,8 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||||
|
// Match the upstream GPU texture bound without depending on a newer local image decoder.
|
||||||
|
private const val MAX_JXL_PIXEL_SIZE = 16384
|
||||||
|
|
||||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||||
|
|
||||||
@@ -56,10 +59,9 @@ private val RAW_MIME_TYPES = setOf(
|
|||||||
"image/x-sony-sr2", "image/x-sony-srf", "image/x3f",
|
"image/x-sony-sr2", "image/x-sony-srf", "image/x3f",
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun isRawMime(contentType: String?): Boolean {
|
private fun mimeType(contentType: String?) = contentType?.substringBefore(';')?.trim()?.lowercase()
|
||||||
val mime = contentType?.substringBefore(';')?.trim()?.lowercase() ?: return false
|
|
||||||
return mime in RAW_MIME_TYPES
|
private fun isRawMime(contentType: String?) = mimeType(contentType) in RAW_MIME_TYPES
|
||||||
}
|
|
||||||
|
|
||||||
class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||||
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
|
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
|
||||||
@@ -97,6 +99,42 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
return@fetch callback(CANCELLED)
|
return@fetch callback(CANCELLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Neither Android nor Flutter can decode JPEG XL, so this applies to encoded requests too.
|
||||||
|
if (mimeType(contentType) == "image/jxl") {
|
||||||
|
decodeExecutor.execute {
|
||||||
|
val info = IntArray(3)
|
||||||
|
// The fetch is done, so cancelling now stops the decode instead.
|
||||||
|
val cancelled = ByteBuffer.allocateDirect(1)
|
||||||
|
signal.setOnCancelListener { NativeImage.cancel(cancelled) }
|
||||||
|
val pointer = if (signal.isCanceled) 0L else NativeImage.decodeJxl(
|
||||||
|
buffer.pointer,
|
||||||
|
buffer.offset,
|
||||||
|
width?.toInt() ?: 0,
|
||||||
|
height?.toInt() ?: 0,
|
||||||
|
MAX_JXL_PIXEL_SIZE,
|
||||||
|
cancelled,
|
||||||
|
info
|
||||||
|
)
|
||||||
|
buffer.free()
|
||||||
|
requestMap.remove(requestId)
|
||||||
|
callback(
|
||||||
|
when {
|
||||||
|
pointer != 0L -> Result.success(
|
||||||
|
mapOf(
|
||||||
|
"pointer" to pointer,
|
||||||
|
"width" to info[0].toLong(),
|
||||||
|
"height" to info[1].toLong(),
|
||||||
|
"rowBytes" to info[2].toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
signal.isCanceled -> CANCELLED
|
||||||
|
else -> Result.failure(IOException("Failed to decode JPEG XL image"))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@fetch
|
||||||
|
}
|
||||||
|
|
||||||
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
|
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
|
||||||
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
|
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
|
||||||
// dedicated pool - the fetch callback threads are shared with video streaming. On any
|
// dedicated pool - the fetch callback threads are shared with video streaming. On any
|
||||||
@@ -272,7 +310,11 @@ private class CronetImageFetcher : ImageFetcher {
|
|||||||
requestBuilder.addHeader(key, value)
|
requestBuilder.addHeader(key, value)
|
||||||
}
|
}
|
||||||
val request = requestBuilder.build()
|
val request = requestBuilder.build()
|
||||||
signal.setOnCancelListener(request::cancel)
|
// Cronet's disk cache can still use a read buffer after onCanceled, so only cancel between reads.
|
||||||
|
signal.setOnCancelListener {
|
||||||
|
callback.requestCancel = true
|
||||||
|
if (!callback.receivingResponse) request.cancel()
|
||||||
|
}
|
||||||
request.start()
|
request.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,12 +370,16 @@ private class CronetImageFetcher : ImageFetcher {
|
|||||||
) : UrlRequest.Callback() {
|
) : UrlRequest.Callback() {
|
||||||
private var buffer: NativeByteBuffer? = null
|
private var buffer: NativeByteBuffer? = null
|
||||||
private var error: Exception? = null
|
private var error: Exception? = null
|
||||||
|
@Volatile var receivingResponse = false
|
||||||
|
@Volatile var requestCancel = false
|
||||||
|
|
||||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
|
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
|
||||||
request.followRedirect()
|
request.followRedirect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
|
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
|
||||||
|
receivingResponse = true
|
||||||
|
if (requestCancel) return request.cancel()
|
||||||
if (info.httpStatusCode !in 200..299) {
|
if (info.httpStatusCode !in 200..299) {
|
||||||
error = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
|
error = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
|
||||||
return request.cancel()
|
return request.cancel()
|
||||||
@@ -361,6 +407,7 @@ private class CronetImageFetcher : ImageFetcher {
|
|||||||
info: UrlResponseInfo,
|
info: UrlResponseInfo,
|
||||||
byteBuffer: ByteBuffer
|
byteBuffer: ByteBuffer
|
||||||
) {
|
) {
|
||||||
|
if (requestCancel) return request.cancel()
|
||||||
try {
|
try {
|
||||||
// Always pass a fresh wrap so byteBuffer.position() represents only the
|
// Always pass a fresh wrap so byteBuffer.position() represents only the
|
||||||
// bytes Cronet wrote in this iteration. Reusing the caller-supplied
|
// bytes Cronet wrote in this iteration. Reusing the caller-supplied
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:native_core/native_core.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('native core loads on device', () {
|
||||||
|
expect(version(), isNotEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NativeCore {
|
||||||
|
private static let library = dlopen("@rpath/native_core_ffi.framework/native_core_ffi", RTLD_NOW)
|
||||||
|
|
||||||
|
private static func symbol<T>(_ name: String, as type: T.Type) -> T? {
|
||||||
|
guard let library, let symbol = dlsym(library, name) else { return nil }
|
||||||
|
return unsafeBitCast(symbol, to: type)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let coreThumbhash = symbol("native_core_thumbhash", as: NativeCoreThumbhashFn.self)
|
||||||
|
private static let coreJxlDecode = symbol("native_core_jxl_decode", as: NativeCoreJxlDecodeFn.self)
|
||||||
|
private static let coreCancel = symbol("native_core_cancel", as: NativeCoreCancelFn.self)
|
||||||
|
|
||||||
|
static func thumbhash(_ hash: Data) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
||||||
|
guard let coreThumbhash else { return nil }
|
||||||
|
var width: Int32 = 0
|
||||||
|
var height: Int32 = 0
|
||||||
|
return hash.withUnsafeBytes { bytes in
|
||||||
|
guard let pointer = coreThumbhash(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, &width, &height)
|
||||||
|
else { return nil }
|
||||||
|
return (Int(width), Int(height), UnsafeMutableRawPointer(pointer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodeJxl(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
||||||
|
guard let coreJxlDecode else { return nil }
|
||||||
|
var info = NativeCoreImageInfo()
|
||||||
|
return data.withUnsafeBytes { bytes in
|
||||||
|
guard let pointer = coreJxlDecode(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
|
||||||
|
else { return nil }
|
||||||
|
return (Int(info.width), Int(info.height), UnsafeMutableRawPointer(pointer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func cancel(_ flag: UnsafeMutablePointer<Bool>) {
|
||||||
|
coreCancel?(flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,4 +9,6 @@ enum ImageProcessing {
|
|||||||
return q
|
return q
|
||||||
}()
|
}()
|
||||||
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
|
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
|
||||||
|
// Impeller shrinks each side to the GPU texture limit on its own, which squishes long images.
|
||||||
|
static let maxPixelSize = 16384
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
requestOptions.version = .current
|
requestOptions.version = .current
|
||||||
return requestOptions
|
return requestOptions
|
||||||
}()
|
}()
|
||||||
private static let maxPixelSize: CGFloat = 16384
|
|
||||||
|
|
||||||
private static let registry = RequestRegistry<ImageRequest>()
|
private static let registry = RequestRegistry<ImageRequest>()
|
||||||
|
|
||||||
@@ -111,7 +110,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
|
|
||||||
let isOriginal = !(width > 0 && height > 0)
|
let isOriginal = !(width > 0 && height > 0)
|
||||||
let targetSize = isOriginal
|
let targetSize = isOriginal
|
||||||
? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize)
|
? CGSize(width: ImageProcessing.maxPixelSize, height: ImageProcessing.maxPixelSize)
|
||||||
: CGSize(width: Double(width), height: Double(height))
|
: CGSize(width: Double(width), height: Double(height))
|
||||||
let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill
|
let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill
|
||||||
var image: UIImage?
|
var image: UIImage?
|
||||||
@@ -136,7 +135,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// .fast can return larger than the target, so retry with .exact to guarantee the bound.
|
// .fast can return larger than the target, so retry with .exact to guarantee the bound.
|
||||||
if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) {
|
if max(cgImage.width, cgImage.height) > ImageProcessing.maxPixelSize {
|
||||||
let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions
|
let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions
|
||||||
exactOptions.resizeMode = .exact
|
exactOptions.resizeMode = .exact
|
||||||
image = nil
|
image = nil
|
||||||
|
|||||||
@@ -7,15 +7,23 @@ import Photos
|
|||||||
final class RemoteImageRequest: ImageRequest {
|
final class RemoteImageRequest: ImageRequest {
|
||||||
var task: URLSessionDataTask?
|
var task: URLSessionDataTask?
|
||||||
let id: Int64
|
let id: Int64
|
||||||
|
/// Lets cancellation stop a native decode partway through.
|
||||||
|
let decodeCancelled = UnsafeMutablePointer<Bool>.allocate(capacity: 1)
|
||||||
|
|
||||||
init(id: Int64, completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) {
|
init(id: Int64, completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) {
|
||||||
self.id = id
|
self.id = id
|
||||||
super.init(completion: completion)
|
super.init(completion: completion)
|
||||||
|
decodeCancelled.initialize(to: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
decodeCancelled.deallocate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func cancel() {
|
override func cancel() {
|
||||||
super.cancel()
|
super.cancel()
|
||||||
task?.cancel()
|
task?.cancel()
|
||||||
|
NativeCore.cancel(decodeCancelled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +73,38 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flutter can't decode JPEG XL and older iOS versions can't either, so this applies to encoded requests too.
|
||||||
|
if response?.mimeType == "image/jxl" {
|
||||||
|
ImageProcessing.queue.addOperation {
|
||||||
|
if request.isCancelled {
|
||||||
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let (width, height, pointer) = NativeCore.decodeJxl(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else {
|
||||||
|
registry.remove(requestId: request.id)
|
||||||
|
if request.isCancelled {
|
||||||
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
|
}
|
||||||
|
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode JPEG XL image", details: nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.isCancelled {
|
||||||
|
free(pointer)
|
||||||
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.remove(requestId: request.id)
|
||||||
|
request.completion(
|
||||||
|
.success([
|
||||||
|
"pointer": Int64(Int(bitPattern: pointer)),
|
||||||
|
"width": Int64(width),
|
||||||
|
"height": Int64(height),
|
||||||
|
"rowBytes": Int64(width * 4),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if encoded {
|
if encoded {
|
||||||
let length = data.count
|
let length = data.count
|
||||||
let pointer = malloc(length)!
|
let pointer = malloc(length)!
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
#import "GeneratedPluginRegistrant.h"
|
#import "GeneratedPluginRegistrant.h"
|
||||||
|
#include "../../../native/crates/native_core_ffi/include/native_core.h"
|
||||||
|
|
||||||
|
// The function pointer type comes from the declaration, so nothing is retyped here.
|
||||||
|
typedef __typeof__(&native_core_thumbhash) NativeCoreThumbhashFn;
|
||||||
|
typedef __typeof__(&native_core_jxl_decode) NativeCoreJxlDecodeFn;
|
||||||
|
typedef __typeof__(&native_core_cancel) NativeCoreCancelFn;
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ abstract class ImageRequest {
|
|||||||
return (codec, descriptor);
|
return (codec, descriptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async {
|
Future<ui.FrameInfo?> _firstFrame((ui.Codec, ui.ImageDescriptor)? result) async {
|
||||||
final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize);
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -116,7 +115,12 @@ abstract class ImageRequest {
|
|||||||
return ((width * scale).ceil(), (height * scale).ceil());
|
return ((width * scale).ceil(), (height * scale).ceil());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
|
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromDecodedPlatformImage(
|
||||||
|
int address,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
int rowBytes,
|
||||||
|
) async {
|
||||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
malloc.free(pointer);
|
malloc.free(pointer);
|
||||||
@@ -152,14 +156,6 @@ abstract class ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final frame = await codec.getNextFrame();
|
return (codec, descriptor);
|
||||||
descriptor.dispose();
|
|
||||||
codec.dispose();
|
|
||||||
if (_isCancelled) {
|
|
||||||
frame.image.dispose();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return frame;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ class LocalImageRequest extends ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
final frame = await _firstFrame(
|
||||||
|
await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!),
|
||||||
|
);
|
||||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,22 +21,7 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
width: decodeSize?.width.ceil(),
|
width: decodeSize?.width.ceil(),
|
||||||
height: decodeSize?.height.ceil(),
|
height: decodeSize?.height.ceil(),
|
||||||
);
|
);
|
||||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
final frame = await _firstFrame(await _codecFromResponse(info, decodeSize: decodeSize));
|
||||||
final frame = switch (info) {
|
|
||||||
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(
|
|
||||||
pointer,
|
|
||||||
length,
|
|
||||||
decodeSize: decodeSize,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
'pointer': final int pointer,
|
|
||||||
'width': final int width,
|
|
||||||
'height': final int height,
|
|
||||||
'rowBytes': final int rowBytes,
|
|
||||||
} =>
|
|
||||||
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,14 +38,29 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
);
|
);
|
||||||
if (info == null) {
|
final result = await _codecFromResponse(info);
|
||||||
return null;
|
return result?.$1;
|
||||||
}
|
|
||||||
|
|
||||||
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
|
||||||
return codec;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android falls back to encoded data if native decoding fails, and JPEG XL arrives decoded even when the encoded image
|
||||||
|
// is requested, so check for both shapes of the response.
|
||||||
|
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromResponse(Map<String, int>? info, {ui.Size? decodeSize}) =>
|
||||||
|
switch (info) {
|
||||||
|
{'pointer': final int pointer, 'length': final int length} => _codecFromEncodedPlatformImage(
|
||||||
|
pointer,
|
||||||
|
length,
|
||||||
|
decodeSize: decodeSize,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
'pointer': final int pointer,
|
||||||
|
'width': final int width,
|
||||||
|
'height': final int height,
|
||||||
|
'rowBytes': final int rowBytes,
|
||||||
|
} =>
|
||||||
|
_codecFromDecodedPlatformImage(pointer, width, height, rowBytes),
|
||||||
|
_ => Future.value(),
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> _onCancelled() {
|
Future<void> _onCancelled() {
|
||||||
return remoteImageApi.cancelRequest(requestId);
|
return remoteImageApi.cancelRequest(requestId);
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ class ThumbhashImageRequest extends ImageRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash);
|
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash);
|
||||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
final frame = await _firstFrame(
|
||||||
|
await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!),
|
||||||
|
);
|
||||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,23 @@ class RemoteAssetRepository extends DatabaseAccessor<Drift> with $RemoteAssetRep
|
|||||||
return _assetSelectable(id).watchSingleOrNull();
|
return _assetSelectable(id).watchSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stream<Set<String>> watchDeletedAssetIds(Iterable<String> ids, AssetVisibility visibility) {
|
||||||
|
final idList = ids.toList(growable: false);
|
||||||
|
if (idList.isEmpty) {
|
||||||
|
return Stream.value(const <String>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
final query = _db.remoteAssetEntity.selectOnly()
|
||||||
|
..addColumns([_db.remoteAssetEntity.id])
|
||||||
|
..where(
|
||||||
|
_db.remoteAssetEntity.id.isIn(idList) &
|
||||||
|
(_db.remoteAssetEntity.deletedAt.isNotNull() |
|
||||||
|
_db.remoteAssetEntity.visibility.equalsValue(visibility).not()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).watch().map((rows) => rows.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
Future<RemoteAsset?> get(String id) {
|
Future<RemoteAsset?> get(String id) {
|
||||||
return _assetSelectable(id).getSingleOrNull();
|
return _assetSelectable(id).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'dart:async';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/search.service.dart';
|
import 'package:immich_mobile/domain/services/search.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||||
|
|
||||||
final searchPreFilterProvider = NotifierProvider<SearchFilterProvider, SearchFilter?>(SearchFilterProvider.new);
|
final searchPreFilterProvider = NotifierProvider<SearchFilterProvider, SearchFilter?>(SearchFilterProvider.new);
|
||||||
@@ -32,16 +34,21 @@ class SearchState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
|
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
|
||||||
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider), ref.watch(driftProvider).remoteAssetRepository),
|
||||||
);
|
);
|
||||||
|
|
||||||
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
|
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
|
||||||
final SearchService _searchService;
|
final SearchService _searchService;
|
||||||
|
final RemoteAssetRepository _remoteAssetRepository;
|
||||||
final _assetCountController = StreamController<int>.broadcast();
|
final _assetCountController = StreamController<int>.broadcast();
|
||||||
|
|
||||||
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
|
StreamSubscription<Set<String>>? _deletedIdsSubscription;
|
||||||
|
List<BaseAsset> _results = const [];
|
||||||
|
int? _nextPage = 1;
|
||||||
|
|
||||||
Stream<int> get assetCount => _assetCountController.stream;
|
PaginatedSearchNotifier(this._searchService, this._remoteAssetRepository) : super(const SearchState());
|
||||||
|
|
||||||
|
Stream<int> get assetCount => _assetCountController.stream.distinct();
|
||||||
|
|
||||||
Future<void> search(SearchFilter filter) async {
|
Future<void> search(SearchFilter filter) async {
|
||||||
if (state.nextPage == null || state.isLoading) {
|
if (state.nextPage == null || state.isLoading) {
|
||||||
@@ -52,24 +59,47 @@ class PaginatedSearchNotifier extends StateNotifier<SearchState> {
|
|||||||
|
|
||||||
final result = await _searchService.search(filter, state.nextPage!);
|
final result = await _searchService.search(filter, state.nextPage!);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
state = SearchState(assets: state.assets, nextPage: state.nextPage);
|
state = SearchState(assets: state.assets, nextPage: state.nextPage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final assets = [...state.assets, ...result.assets];
|
_results = [..._results, ...result.assets];
|
||||||
state = SearchState(assets: assets, nextPage: result.nextPage);
|
_nextPage = result.nextPage;
|
||||||
|
_watchDeletedIds(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline);
|
||||||
_assetCountController.add(assets.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
state = const SearchState();
|
unawaited(_deletedIdsSubscription?.cancel());
|
||||||
_assetCountController.add(0);
|
_deletedIdsSubscription = null;
|
||||||
|
_results = const [];
|
||||||
|
_nextPage = 1;
|
||||||
|
_emit(const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _watchDeletedIds(AssetVisibility visibility) {
|
||||||
|
unawaited(_deletedIdsSubscription?.cancel());
|
||||||
|
|
||||||
|
final ids = _results.whereType<RemoteAsset>().map((asset) => asset.id);
|
||||||
|
_deletedIdsSubscription = _remoteAssetRepository.watchDeletedAssetIds(ids, visibility).listen(_emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emit(Set<String> deletedIds) {
|
||||||
|
final visible = deletedIds.isEmpty
|
||||||
|
? _results
|
||||||
|
: _results.where((asset) => asset is! RemoteAsset || !deletedIds.contains(asset.id)).toList(growable: false);
|
||||||
|
|
||||||
|
state = SearchState(assets: visible, nextPage: _nextPage);
|
||||||
|
_assetCountController.add(visible.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
unawaited(_deletedIdsSubscription?.cancel());
|
||||||
unawaited(_assetCountController.close());
|
unawaited(_assetCountController.close());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
java = "21.0.2"
|
java = "21.0.2"
|
||||||
|
rust = { version = "1.99.1", targets = "aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios" }
|
||||||
|
|
||||||
[tools."aqua:flutter/flutter"]
|
[tools."aqua:flutter/flutter"]
|
||||||
version = "3.47.2"
|
version = "3.47.2"
|
||||||
@@ -34,6 +35,7 @@ depends = [
|
|||||||
"codegen:dart",
|
"codegen:dart",
|
||||||
"codegen:drift:migration",
|
"codegen:drift:migration",
|
||||||
"codegen:drift:schema",
|
"codegen:drift:schema",
|
||||||
|
"codegen:native",
|
||||||
"codegen:pigeon",
|
"codegen:pigeon",
|
||||||
"codegen:translation",
|
"codegen:translation",
|
||||||
]
|
]
|
||||||
@@ -80,6 +82,11 @@ alias = "watch"
|
|||||||
description = "Watch and auto-generate dart code"
|
description = "Watch and auto-generate dart code"
|
||||||
run = "dart run build_runner watch"
|
run = "dart run build_runner watch"
|
||||||
|
|
||||||
|
[tasks."codegen:native"]
|
||||||
|
description = "Generate the native C header and Dart bindings"
|
||||||
|
dir = "../native/native_core"
|
||||||
|
run = ["cargo build -p native_core_ffi", "flutter pub get", "dart run ffigen --config ffigen.yaml"]
|
||||||
|
|
||||||
[tasks."codegen:pigeon"]
|
[tasks."codegen:pigeon"]
|
||||||
alias = "pigeon"
|
alias = "pigeon"
|
||||||
description = "Generate pigeon platform code"
|
description = "Generate pigeon platform code"
|
||||||
|
|||||||
+27
-4
@@ -796,10 +796,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.2.0"
|
||||||
hooks_riverpod:
|
hooks_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1132,6 +1132,13 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
|
native_core:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../native/native_core"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.0"
|
||||||
native_toolchain_c:
|
native_toolchain_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1140,6 +1147,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.1"
|
version: "0.19.1"
|
||||||
|
native_toolchain_rust:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_rust
|
||||||
|
sha256: "8469a0e32a47a5c84b7fd300de9fd0e8082aac76c37922986324f5082a58350d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.6"
|
||||||
native_video_player:
|
native_video_player:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1458,10 +1473,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: record_use
|
name: record_use
|
||||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "1.1.1"
|
||||||
riverpod:
|
riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1772,6 +1787,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.4"
|
version: "0.9.4"
|
||||||
|
toml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: toml
|
||||||
|
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.18.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 3.3.5-local.8+3030501
|
version: 3.3.5-local.11+3030503
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.12.0 <4.0.0'
|
sdk: '>=3.12.0 <4.0.0'
|
||||||
@@ -39,6 +39,8 @@ dependencies:
|
|||||||
hooks_riverpod: ^2.6.1
|
hooks_riverpod: ^2.6.1
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
image_picker: ^1.2.3
|
image_picker: ^1.2.3
|
||||||
|
native_core:
|
||||||
|
path: ../native/native_core
|
||||||
immich_ui:
|
immich_ui:
|
||||||
path: './packages/ui'
|
path: './packages/ui'
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ void main() {
|
|||||||
expect(args[4], isNull);
|
expect(args[4], isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts a decoded image for an animation request', () async {
|
||||||
|
final pointer = malloc<Uint8>(8)..asTypedList(8).setAll(0, [255, 0, 0, 255, 0, 0, 255, 255]);
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, (
|
||||||
|
message,
|
||||||
|
) async {
|
||||||
|
return <Object?>[
|
||||||
|
<Object?, Object?>{'pointer': pointer.address, 'width': 2, 'height': 1, 'rowBytes': 8},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
final codec = (await RemoteImageRequest(uri: 'https://example.test/animation').loadCodec())!;
|
||||||
|
final image = (await codec.getNextFrame()).image;
|
||||||
|
|
||||||
|
expect((image.width, image.height), (2, 1));
|
||||||
|
image.dispose();
|
||||||
|
codec.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps portrait cover quality in a wide tile', () async {
|
test('keeps portrait cover quality in a wide tile', () async {
|
||||||
final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size(963, 642));
|
final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size(963, 642));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import 'package:async/async.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
|
||||||
import '../repository_context.dart';
|
import '../repository_context.dart';
|
||||||
@@ -64,4 +66,53 @@ void main() {
|
|||||||
expect(result[0].id, remote.id);
|
expect(result[0].id, remote.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('watchDeletedAssetIds', () {
|
||||||
|
late String userId;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
final user = await ctx.newUser();
|
||||||
|
userId = user.id;
|
||||||
|
await ctx.newAuthUser(id: userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides trashed and archived assets but keeps matching and unsynced ones', () async {
|
||||||
|
final timeline = await ctx.newRemoteAsset(ownerId: userId);
|
||||||
|
final trashed = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2020));
|
||||||
|
final archived = await ctx.newRemoteAsset(ownerId: userId, visibility: AssetVisibility.archive);
|
||||||
|
|
||||||
|
final result = await sut.watchDeletedAssetIds([
|
||||||
|
timeline.id,
|
||||||
|
trashed.id,
|
||||||
|
archived.id,
|
||||||
|
'never-synced',
|
||||||
|
], AssetVisibility.timeline).first;
|
||||||
|
|
||||||
|
expect(result, {trashed.id, archived.id});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides assets not matching the requested visibility for archive searches', () async {
|
||||||
|
final timeline = await ctx.newRemoteAsset(ownerId: userId);
|
||||||
|
final archived = await ctx.newRemoteAsset(ownerId: userId, visibility: AssetVisibility.archive);
|
||||||
|
|
||||||
|
final result = await sut.watchDeletedAssetIds([timeline.id, archived.id], AssetVisibility.archive).first;
|
||||||
|
|
||||||
|
expect(result, {timeline.id});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits when an asset is trashed and again when it is restored', () async {
|
||||||
|
final asset = await ctx.newRemoteAsset(ownerId: userId);
|
||||||
|
final queue = StreamQueue(sut.watchDeletedAssetIds([asset.id], AssetVisibility.timeline));
|
||||||
|
|
||||||
|
expect(await queue.next, isEmpty);
|
||||||
|
|
||||||
|
await sut.trash([asset.id]);
|
||||||
|
expect(await queue.next, {asset.id});
|
||||||
|
|
||||||
|
await sut.restoreTrash([asset.id]);
|
||||||
|
expect(await queue.next, isEmpty);
|
||||||
|
|
||||||
|
await queue.cancel();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Link against minSdk (mobile/android/app/build.gradle) instead of the API 35 that native_toolchain_rust picks.
|
||||||
|
[target.aarch64-linux-android]
|
||||||
|
rustflags = ["-C", "link-arg=--target=aarch64-linux-android26"]
|
||||||
|
|
||||||
|
# Neon on 32 bit arm is the NDK's default for C, the rust target leaves it off.
|
||||||
|
[target.armv7-linux-androideabi]
|
||||||
|
rustflags = ["-C", "link-arg=--target=armv7a-linux-androideabi26", "-C", "target-feature=+neon"]
|
||||||
|
|
||||||
|
[target.x86_64-linux-android]
|
||||||
|
rustflags = ["-C", "link-arg=--target=x86_64-linux-android26"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
/crates/native_core_ffi/include/native_core.h
|
||||||
|
/native_core/lib/src/bindings.g.dart
|
||||||
Generated
+659
@@ -0,0 +1,659 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "array-init"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbindgen"
|
||||||
|
version = "0.29.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"tempfile",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "combine"
|
||||||
|
version = "4.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-deque"
|
||||||
|
version = "0.8.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-epoch"
|
||||||
|
version = "0.9.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys-macros"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jxl"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8379fc83d79477900c9aed78cb8370a556d834081bebcb771c6010381c4e230"
|
||||||
|
dependencies = [
|
||||||
|
"array-init",
|
||||||
|
"byteorder",
|
||||||
|
"jxl_macros",
|
||||||
|
"jxl_simd",
|
||||||
|
"jxl_transforms",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jxl_macros"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eeb27c06a9957bd9c8e65d0aeea0db6a555a9e1c85597e284f8304015c410c87"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jxl_simd"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a645451b4d34b4017caf24110a14eb737e275b5d3d121bc754e67727ee65af0e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jxl_transforms"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21c4b7f8957f5ba578b2e28cd5ebbc97dad58a3ad5ff26765da61e8dab8f1cad"
|
||||||
|
dependencies = [
|
||||||
|
"jxl_simd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.190"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "396077b717181ce3f9a6b66739b269b82d756be5ec0f76be0bc40a7c51775830"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"jxl",
|
||||||
|
"libc",
|
||||||
|
"moxcms",
|
||||||
|
"pic-scale",
|
||||||
|
"quick_cache",
|
||||||
|
"rayon",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native_core_ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cbindgen",
|
||||||
|
"jni",
|
||||||
|
"libc",
|
||||||
|
"native_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pic-scale"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick_cache"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "403c1a912fec895cafb223201e368234842acb9220aaf08ab042ae89ba5f135c"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"rayon-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon-core"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-deque",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.9.12+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.7.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.3+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||||
|
dependencies = [
|
||||||
|
"winnow 1.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
members = ["crates/native_core", "crates/native_core_ffi"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
cbindgen = { version = "0.29", default-features = false }
|
||||||
|
jni = { version = "0.22", default-features = false }
|
||||||
|
jxl = { version = "0.8", default-features = false, features = ["neon"] }
|
||||||
|
libc = { version = "0.2", default-features = false }
|
||||||
|
moxcms = { version = "0.9", default-features = false, features = ["neon_luts", "neon_shaper_paths"] }
|
||||||
|
pic-scale = { version = "0.7", default-features = false, features = ["neon", "rdm"] }
|
||||||
|
quick_cache = { version = "0.7", default-features = false }
|
||||||
|
rayon = { version = "1.11", default-features = false }
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
undocumented_unsafe_blocks = "deny"
|
||||||
|
|
||||||
|
# catch_unwind at the FFI boundary needs unwinding, so no panic = "abort" here.
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Native core
|
||||||
|
|
||||||
|
Rust code that ships inside the mobile app. Flutter compiles it from source through the
|
||||||
|
`native_core` build hook, so building the app needs a Rust toolchain: `mise install`
|
||||||
|
in `mobile/` sets one up, otherwise install [rustup](https://rustup.rs) once and the build
|
||||||
|
fetches the pinned toolchain itself.
|
||||||
|
|
||||||
|
- `crates/native_core`: the shared logic
|
||||||
|
- `crates/native_core_ffi`: the C ABI, cbindgen writes `include/native_core.h`
|
||||||
|
- `native_core`: Flutter package with the build hook and the ffigen bindings
|
||||||
|
(`NOTICES` carries the licenses of the crates compiled into the core, update it with the dependencies)
|
||||||
|
|
||||||
|
`mise run build`, `test`, `lint`, and `fmt`. The header and the Dart bindings come from `mise run //mobile:codegen:native`.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "native_core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
jxl = { workspace = true }
|
||||||
|
libc = { workspace = true }
|
||||||
|
moxcms = { workspace = true }
|
||||||
|
pic-scale = { workspace = true }
|
||||||
|
quick_cache = { workspace = true }
|
||||||
|
rayon = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
pub use moxcms::{CicpColorPrimaries, CicpProfile, TransferCharacteristics};
|
||||||
|
use moxcms::{
|
||||||
|
ColorProfile, DataColorSpace, Layout, MatrixCoefficients, RenderingIntent,
|
||||||
|
Transform16BitExecutor, TransformOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::image::Pixels;
|
||||||
|
|
||||||
|
/// The colour space decoded pixels should be in.
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub enum Target {
|
||||||
|
/// sRGB, converting and tone-mapping as needed.
|
||||||
|
Srgb,
|
||||||
|
/// The image's own space whenever CICP can describe it, otherwise sRGB.
|
||||||
|
Source,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const SRGB: CicpProfile = CicpProfile {
|
||||||
|
color_primaries: CicpColorPrimaries::Bt709,
|
||||||
|
transfer_characteristics: TransferCharacteristics::Srgb,
|
||||||
|
matrix_coefficients: MatrixCoefficients::Identity,
|
||||||
|
full_range: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn is_srgb(cicp: CicpProfile) -> bool {
|
||||||
|
cicp.color_primaries == SRGB.color_primaries
|
||||||
|
&& cicp.transfer_characteristics == SRGB.transfer_characteristics
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_hdr(cicp: CicpProfile) -> bool {
|
||||||
|
matches!(
|
||||||
|
cicp.transfer_characteristics,
|
||||||
|
TransferCharacteristics::Smpte2084 | TransferCharacteristics::Hlg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the transform from an ICC profile to sRGB, and the layout of the 12-bit samples it reads.
|
||||||
|
pub(crate) fn to_srgb(icc: &[u8]) -> Option<(Arc<Transform16BitExecutor>, Layout)> {
|
||||||
|
let source = ColorProfile::new_from_slice(icc).ok()?;
|
||||||
|
let layout = match source.color_space {
|
||||||
|
DataColorSpace::Gray => Layout::GrayAlpha,
|
||||||
|
_ => Layout::Rgba,
|
||||||
|
};
|
||||||
|
let options = TransformOptions {
|
||||||
|
// Profiles that tone-map HDR, like those jxl-rs makes, may only have a perceptual table.
|
||||||
|
rendering_intent: RenderingIntent::Perceptual,
|
||||||
|
// The fixed-point path loses precision with the narrower samples it reads.
|
||||||
|
prefer_fixed_point: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Going past 12-bit makes the transform's tables much slower to build for negligible benefit.
|
||||||
|
let transform = source
|
||||||
|
.create_transform_12bit(layout, &ColorProfile::new_srgb(), Layout::Rgba, options)
|
||||||
|
.ok()?;
|
||||||
|
Some((transform, layout))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the 12-bit samples in `rgba`, `stride` bytes per row, to RGBA8 with `transform`.
|
||||||
|
pub(crate) fn convert_bands(
|
||||||
|
transform: &Transform16BitExecutor,
|
||||||
|
rgba: &mut Pixels<u8>,
|
||||||
|
stride: usize,
|
||||||
|
cancelled: &AtomicBool,
|
||||||
|
) -> Option<()> {
|
||||||
|
let (w, h) = (rgba.width(), rgba.height());
|
||||||
|
let buf = rgba.bytes_mut();
|
||||||
|
// Narrowing in place keeps peak memory at the wide decode plus one band.
|
||||||
|
let rows = 64;
|
||||||
|
let mut converted = vec![0u16; w * 4 * rows.min(h)];
|
||||||
|
for y in (0..h).step_by(rows) {
|
||||||
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = rows.min(h - y);
|
||||||
|
let band = &buf[y * stride..][..n * stride];
|
||||||
|
// SAFETY: any two bytes are a valid u16.
|
||||||
|
let ([], src, []) = (unsafe { band.align_to::<u16>() }) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
transform.transform(src, &mut converted[..n * w * 4]).ok()?;
|
||||||
|
// Pixels decode at least as wide as RGBA8, so this only overwrites converted rows.
|
||||||
|
let narrowed = &mut buf[y * w * 4..][..n * w * 4];
|
||||||
|
for (dst, &src) in narrowed.iter_mut().zip(&converted) {
|
||||||
|
const NARROW: u32 = ((255 << 16) + 4095 / 2) / 4095;
|
||||||
|
*dst = ((u32::from(src) * NARROW + (1 << 15)) >> 16) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/// Converts `h` rows of `w` RGBA_1010102 pixels, `stride` pixels apart in `src`, to dense RGBA_8888
|
||||||
|
/// in `dst`.
|
||||||
|
pub fn convert_1010102(src: &[u32], stride: usize, w: usize, h: usize, dst: &mut [u32]) {
|
||||||
|
// Each 10-bit channel rounds v*255/1023 (16.16 fixed point) and alpha maps to a*85. Plain
|
||||||
|
// arithmetic on purpose: the loop auto-vectorizes and beat a lookup table on-device.
|
||||||
|
for y in 0..h {
|
||||||
|
for (out, &px) in dst[y * w..][..w].iter_mut().zip(&src[y * stride..][..w]) {
|
||||||
|
let r = ((px & 0x3ff) * 16336 + 32768) >> 16;
|
||||||
|
let g = (((px >> 10) & 0x3ff) * 16336 + 32768) >> 16;
|
||||||
|
let b = (((px >> 20) & 0x3ff) * 16336 + 32768) >> 16;
|
||||||
|
let a = (px >> 30) * 85;
|
||||||
|
*out = r | (g << 8) | (b << 16) | (a << 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::mem::ManuallyDrop;
|
||||||
|
use std::ptr::NonNull;
|
||||||
|
use std::slice;
|
||||||
|
|
||||||
|
use crate::color::CicpProfile;
|
||||||
|
use crate::resize::{self, Lanczos};
|
||||||
|
|
||||||
|
/// Decoded pixels and the colour space they are in.
|
||||||
|
pub struct Image {
|
||||||
|
pub cicp: CicpProfile,
|
||||||
|
pub samples: Samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Samples {
|
||||||
|
U8(Pixels<u8>),
|
||||||
|
/// For HDR transfers, which need more than 8 bits.
|
||||||
|
U16(Pixels<u16>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Premultiplied RGBA pixels in a malloc'd buffer.
|
||||||
|
pub struct Pixels<T> {
|
||||||
|
/// Initialized bytes from calloc or realloc that only self references.
|
||||||
|
buf: NonNull<[u8]>,
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
sample: PhantomData<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait Sample: Lanczos + Ord {
|
||||||
|
const MAX: Self;
|
||||||
|
|
||||||
|
/// Rounds like dividing the product by MAX would.
|
||||||
|
fn scale(self, alpha: Self) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sample for u8 {
|
||||||
|
const MAX: Self = u8::MAX;
|
||||||
|
|
||||||
|
fn scale(self, alpha: Self) -> Self {
|
||||||
|
let x = u16::from(self) * u16::from(alpha) + 128;
|
||||||
|
((x + (x >> 8)) >> 8) as u8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sample for u16 {
|
||||||
|
const MAX: Self = u16::MAX;
|
||||||
|
|
||||||
|
fn scale(self, alpha: Self) -> Self {
|
||||||
|
let x = u32::from(self) * u32::from(alpha) + 32768;
|
||||||
|
((x + (x >> 16)) >> 16) as u16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Pixels<T> {
|
||||||
|
/// Allocates pixels of u8 or u16 samples, the only kinds this module makes, with `pixel_bytes`
|
||||||
|
/// each if a decoder first writes them wider.
|
||||||
|
pub(crate) fn zeroed(width: usize, height: usize, pixel_bytes: usize) -> Option<Self>
|
||||||
|
where
|
||||||
|
T: Sample,
|
||||||
|
{
|
||||||
|
let len = width
|
||||||
|
.checked_mul(height)?
|
||||||
|
.checked_mul(pixel_bytes.max(size_of::<[T; 4]>()))?;
|
||||||
|
// SAFETY: calloc has no preconditions.
|
||||||
|
let ptr = NonNull::new(unsafe { libc::calloc(len, 1) }.cast())?;
|
||||||
|
Some(Self {
|
||||||
|
buf: NonNull::slice_from_raw_parts(ptr, len),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
sample: PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bytes_mut(&mut self) -> &mut [u8] {
|
||||||
|
// SAFETY: self owns these initialized bytes.
|
||||||
|
unsafe { self.buf.as_mut() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn pixels_mut(&mut self) -> &mut [[T; 4]] {
|
||||||
|
// SAFETY: as for pixels, and self is borrowed mutably.
|
||||||
|
unsafe { slice::from_raw_parts_mut(self.buf.as_ptr().cast(), self.width * self.height) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frees everything after the pixels.
|
||||||
|
fn shrink(mut self) -> Option<Self> {
|
||||||
|
let len = self.width * self.height * 4 * size_of::<T>();
|
||||||
|
// SAFETY: the buffer is live and the pixels fill its first len bytes.
|
||||||
|
let shrunk = unsafe { libc::realloc(self.buf.as_ptr().cast(), len) };
|
||||||
|
// A failed realloc leaves the original buffer, which self still frees.
|
||||||
|
self.buf = NonNull::slice_from_raw_parts(NonNull::new(shrunk.cast())?, len);
|
||||||
|
Some(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Premultiplies if `alpha`, then resizes to `size`, or else frees everything after the pixels.
|
||||||
|
pub(crate) fn premultiply_resize(
|
||||||
|
mut self,
|
||||||
|
alpha: bool,
|
||||||
|
size: Option<(usize, usize)>,
|
||||||
|
) -> Option<Self>
|
||||||
|
where
|
||||||
|
T: Sample,
|
||||||
|
{
|
||||||
|
if alpha {
|
||||||
|
self.premultiply();
|
||||||
|
}
|
||||||
|
let Some(size) = size else {
|
||||||
|
return self.shrink();
|
||||||
|
};
|
||||||
|
let mut out = self.resize(size)?;
|
||||||
|
if alpha {
|
||||||
|
// Lanczos3 overshoots, while premultiplied colour must stay within alpha.
|
||||||
|
out.clamp();
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn premultiply(&mut self)
|
||||||
|
where
|
||||||
|
T: Sample,
|
||||||
|
{
|
||||||
|
// Alpha scaling itself at full opacity lets this vectorize as every lane does the same work.
|
||||||
|
for px in self.pixels_mut() {
|
||||||
|
let [r, g, b, a] = *px;
|
||||||
|
*px = [r.scale(a), g.scale(a), b.scale(a), a.scale(T::MAX)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamps colour to alpha.
|
||||||
|
fn clamp(&mut self)
|
||||||
|
where
|
||||||
|
T: Sample,
|
||||||
|
{
|
||||||
|
for px in self.pixels_mut() {
|
||||||
|
let a = px[3];
|
||||||
|
*px = px.map(|c| c.min(a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pixels resampled to `size` with Lanczos3, in a new buffer.
|
||||||
|
fn resize(&self, (tw, th): (usize, usize)) -> Option<Self>
|
||||||
|
where
|
||||||
|
T: Sample,
|
||||||
|
{
|
||||||
|
let mut out = Self::zeroed(tw, th, size_of::<[T; 4]>())?;
|
||||||
|
resize::lanczos(self, &mut out)?;
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(&self) -> usize {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(&self) -> usize {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pixels(&self) -> &[[T; 4]] {
|
||||||
|
// SAFETY: zeroed made room for at least these initialized pixels, and their u8 or u16 samples are
|
||||||
|
// valid for any bytes and aligned by malloc.
|
||||||
|
unsafe { slice::from_raw_parts(self.buf.as_ptr().cast(), self.width * self.height) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands the buffer to a C caller, who frees it with free().
|
||||||
|
pub fn into_raw(self) -> *mut u8 {
|
||||||
|
ManuallyDrop::new(self).buf.as_ptr().cast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Drop for Pixels<T> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: self owns the buffer, which came from calloc or realloc.
|
||||||
|
unsafe { libc::free(self.buf.as_ptr().cast()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_colour_within_alpha() {
|
||||||
|
fn check<T: Sample>(half: T) {
|
||||||
|
let (zero, max) = (T::default(), T::MAX);
|
||||||
|
let mut px = Pixels::<T>::zeroed(16, 16, size_of::<[T; 4]>()).unwrap();
|
||||||
|
// Opaque black against translucent white rings both ways across the edge.
|
||||||
|
for (i, p) in px.pixels_mut().iter_mut().enumerate() {
|
||||||
|
*p = if i % 16 < 8 {
|
||||||
|
[zero, zero, zero, max]
|
||||||
|
} else {
|
||||||
|
[max, max, max, half]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let px = px.premultiply_resize(true, Some((5, 5))).unwrap();
|
||||||
|
for p in px.pixels() {
|
||||||
|
assert!(p[..3].iter().all(|&c| c <= p[3]), "{p:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(128u8);
|
||||||
|
check(32768u16);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use jxl::api::{
|
||||||
|
AlphaMode, Endianness, Event, ExtraChannel, JxlColorEncoding, JxlColorProfile, JxlColorType,
|
||||||
|
JxlDataFormat, JxlDecoder, JxlDecoderOptions, JxlOutputBuffer, JxlParallelRunner,
|
||||||
|
JxlParallelRunnerFun, JxlPixelFormat, JxlPrimaries, JxlTransferFunction, JxlWhitePoint,
|
||||||
|
};
|
||||||
|
use moxcms::{Layout, MatrixCoefficients, Transform16BitExecutor};
|
||||||
|
use quick_cache::sync::Cache;
|
||||||
|
use rayon::prelude::*;
|
||||||
|
|
||||||
|
use crate::color::{self, CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
|
||||||
|
use crate::image::{Image, Pixels, Samples};
|
||||||
|
use crate::resize;
|
||||||
|
|
||||||
|
/// A JPEG XL image whose header has been read.
|
||||||
|
pub struct Jxl<'a> {
|
||||||
|
decoder: JxlDecoder,
|
||||||
|
input: &'a [u8],
|
||||||
|
/// The space of the output.
|
||||||
|
cicp: CicpProfile,
|
||||||
|
/// Converts wide samples to sRGB when the output is not in the source space.
|
||||||
|
transform: Option<Arc<Transform16BitExecutor>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs on the global pool with up to this many threads, or inline with one.
|
||||||
|
struct Rayon(usize);
|
||||||
|
|
||||||
|
impl JxlParallelRunner for Rayon {
|
||||||
|
fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> {
|
||||||
|
(0..num).into_par_iter().try_for_each(fun)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_threads(&self) -> usize {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops handing out work once the flag is set.
|
||||||
|
struct Cancellable<'a> {
|
||||||
|
pool: Rayon,
|
||||||
|
cancelled: &'a AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JxlParallelRunner for Cancellable<'_> {
|
||||||
|
fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> {
|
||||||
|
self.pool.run(num, fun)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num_threads(&self) -> usize {
|
||||||
|
self.pool.num_threads()
|
||||||
|
}
|
||||||
|
|
||||||
|
// jxl-rs only calls this, and each of its tasks loops many items, so check cancellation here.
|
||||||
|
fn run_ordered(
|
||||||
|
&mut self,
|
||||||
|
num: usize,
|
||||||
|
max_threads: Option<usize>,
|
||||||
|
fun: &JxlParallelRunnerFun<'_>,
|
||||||
|
) -> jxl::error::Result<()> {
|
||||||
|
self.pool.run_ordered(num, max_threads, &|i| {
|
||||||
|
if self.cancelled.load(Ordering::Relaxed) {
|
||||||
|
// Any error stops the decode.
|
||||||
|
return Err(jxl::error::Error::ApiUsageError("cancelled"));
|
||||||
|
}
|
||||||
|
fun(i)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Jxl<'a> {
|
||||||
|
pub fn open(mut input: &'a [u8], target: Target) -> Option<Self> {
|
||||||
|
let mut options = JxlDecoderOptions::default();
|
||||||
|
// Colour transforms need straight alpha; premultiplying happens last.
|
||||||
|
options.alpha_mode = AlphaMode::Unpremultiplied;
|
||||||
|
let mut decoder = JxlDecoder::new(options);
|
||||||
|
let Event::BasicInfo = decoder.process(&mut input, None, None).ok()? else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let extra = decoder.basic_info()?.extra_channels.len();
|
||||||
|
// An integer pixel format ensures the output profile below is sRGB rather than float scRGB.
|
||||||
|
decoder
|
||||||
|
.set_pixel_format(JxlPixelFormat::rgba8(extra))
|
||||||
|
.ok()?;
|
||||||
|
let profile = decoder.output_color_profile()?;
|
||||||
|
if let Some(cicp) = cicp(profile)
|
||||||
|
&& (target == Target::Source || color::is_srgb(cicp))
|
||||||
|
{
|
||||||
|
if color::is_hdr(cicp) {
|
||||||
|
decoder
|
||||||
|
.set_pixel_format(JxlPixelFormat::rgba16(extra))
|
||||||
|
.ok()?;
|
||||||
|
}
|
||||||
|
return Some(Self {
|
||||||
|
decoder,
|
||||||
|
input,
|
||||||
|
cicp,
|
||||||
|
transform: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let (transform, layout) = srgb_transform(profile)?;
|
||||||
|
decoder
|
||||||
|
.set_pixel_format(JxlPixelFormat {
|
||||||
|
color_type: match layout {
|
||||||
|
Layout::GrayAlpha => JxlColorType::GrayscaleAlpha,
|
||||||
|
_ => JxlColorType::Rgba,
|
||||||
|
},
|
||||||
|
color_data_format: Some(JxlDataFormat::U16 {
|
||||||
|
endianness: Endianness::native(),
|
||||||
|
bit_depth: 12,
|
||||||
|
}),
|
||||||
|
extra_channel_format: vec![None; extra],
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
Some(Self {
|
||||||
|
decoder,
|
||||||
|
input,
|
||||||
|
cicp: color::SRGB,
|
||||||
|
transform: Some(transform),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the first frame unless cancelled, downscaled to cover `target` pixels and to fit
|
||||||
|
/// within `max_pixel_size`.
|
||||||
|
pub fn decode(
|
||||||
|
mut self,
|
||||||
|
target: Option<(usize, usize)>,
|
||||||
|
max_pixel_size: usize,
|
||||||
|
cancelled: &AtomicBool,
|
||||||
|
) -> Option<Image> {
|
||||||
|
let info = self.decoder.basic_info()?;
|
||||||
|
let (w, h) = info.size;
|
||||||
|
let alpha = info
|
||||||
|
.extra_channels
|
||||||
|
.iter()
|
||||||
|
.any(|ec| ec.ec_type == ExtraChannel::Alpha);
|
||||||
|
let format = self.decoder.current_pixel_format()?;
|
||||||
|
let pixel_bytes =
|
||||||
|
format.color_type.samples_per_pixel() * format.color_data_format?.bytes_per_sample();
|
||||||
|
let stride = w.checked_mul(pixel_bytes)?;
|
||||||
|
let size = resize::cover((w, h), target, max_pixel_size);
|
||||||
|
let samples = if color::is_hdr(self.cicp) {
|
||||||
|
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
|
||||||
|
self.decode_frame(&mut rgba, stride, cancelled)?;
|
||||||
|
Samples::U16(rgba.premultiply_resize(alpha, size)?)
|
||||||
|
} else {
|
||||||
|
// The buffer is sized for the wide decode and ends up holding the RGBA8 result at its
|
||||||
|
// front.
|
||||||
|
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
|
||||||
|
self.decode_frame(&mut rgba, stride, cancelled)?;
|
||||||
|
if let Some(transform) = &self.transform {
|
||||||
|
color::convert_bands(transform.as_ref(), &mut rgba, stride, cancelled)?;
|
||||||
|
}
|
||||||
|
Samples::U8(rgba.premultiply_resize(alpha, size)?)
|
||||||
|
};
|
||||||
|
Some(Image {
|
||||||
|
cicp: self.cicp,
|
||||||
|
samples,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_frame<T>(
|
||||||
|
&mut self,
|
||||||
|
rgba: &mut Pixels<T>,
|
||||||
|
stride: usize,
|
||||||
|
cancelled: &AtomicBool,
|
||||||
|
) -> Option<()> {
|
||||||
|
let (w, h) = (rgba.width(), rgba.height());
|
||||||
|
let buf = rgba.bytes_mut();
|
||||||
|
// The pool only pays off for large images, since thumbs are already decoded in parallel.
|
||||||
|
let threads = if w * h > 512 * 512 {
|
||||||
|
rayon::current_num_threads()
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
let mut runner = Cancellable {
|
||||||
|
pool: Rayon(threads),
|
||||||
|
cancelled,
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let output = &mut [JxlOutputBuffer::new(buf, h, stride)];
|
||||||
|
match self
|
||||||
|
.decoder
|
||||||
|
.process(&mut self.input, Some(output), Some(&mut runner))
|
||||||
|
.ok()?
|
||||||
|
{
|
||||||
|
Event::FrameHeader => {}
|
||||||
|
// Resizing costs ~10% of a thumbnail decode, wasted on tiles that scrolled away
|
||||||
|
// meanwhile.
|
||||||
|
Event::FrameComplete { .. } => {
|
||||||
|
return (!cancelled.load(Ordering::Relaxed)).then_some(());
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The space of the decoded samples, if CICP can describe it.
|
||||||
|
fn cicp(profile: &JxlColorProfile) -> Option<CicpProfile> {
|
||||||
|
let JxlColorProfile::Simple(encoding) = profile else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let (primaries, transfer_function) = match encoding {
|
||||||
|
JxlColorEncoding::RgbColorSpace {
|
||||||
|
white_point: JxlWhitePoint::D65,
|
||||||
|
primaries,
|
||||||
|
transfer_function,
|
||||||
|
..
|
||||||
|
} => (primaries, transfer_function),
|
||||||
|
// Gray samples decode to neutral RGB, which any primaries reproduce.
|
||||||
|
JxlColorEncoding::GrayscaleColorSpace {
|
||||||
|
white_point: JxlWhitePoint::D65,
|
||||||
|
transfer_function,
|
||||||
|
..
|
||||||
|
} => (&JxlPrimaries::SRGB, transfer_function),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(CicpProfile {
|
||||||
|
color_primaries: match primaries {
|
||||||
|
JxlPrimaries::SRGB => CicpColorPrimaries::Bt709,
|
||||||
|
JxlPrimaries::BT2100 => CicpColorPrimaries::Bt2020,
|
||||||
|
JxlPrimaries::P3 => CicpColorPrimaries::Smpte432,
|
||||||
|
JxlPrimaries::Chromaticities { .. } => return None,
|
||||||
|
},
|
||||||
|
transfer_characteristics: match transfer_function {
|
||||||
|
JxlTransferFunction::SRGB => TransferCharacteristics::Srgb,
|
||||||
|
JxlTransferFunction::PQ => TransferCharacteristics::Smpte2084,
|
||||||
|
JxlTransferFunction::HLG => TransferCharacteristics::Hlg,
|
||||||
|
_ => return None,
|
||||||
|
},
|
||||||
|
matrix_coefficients: MatrixCoefficients::Identity,
|
||||||
|
full_range: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reuse the transform to sRGB for a given source profile.
|
||||||
|
type Transforms<K> = Cache<K, (Arc<Transform16BitExecutor>, Layout)>;
|
||||||
|
|
||||||
|
/// [`color::to_srgb`] for `profile`, built once per distinct profile.
|
||||||
|
fn srgb_transform(profile: &JxlColorProfile) -> Option<(Arc<Transform16BitExecutor>, Layout)> {
|
||||||
|
const TRANSFORMS: usize = 8;
|
||||||
|
static ENCODINGS: LazyLock<Transforms<String>> = LazyLock::new(|| Cache::new(TRANSFORMS));
|
||||||
|
static ICCS: LazyLock<Transforms<Vec<u8>>> = LazyLock::new(|| Cache::new(TRANSFORMS));
|
||||||
|
match profile {
|
||||||
|
JxlColorProfile::Simple(encoding) => ENCODINGS
|
||||||
|
.get_or_insert_with(&encoding.get_color_encoding_description(), || {
|
||||||
|
color::to_srgb(&profile.try_as_icc().ok_or(())?).ok_or(())
|
||||||
|
}),
|
||||||
|
JxlColorProfile::Icc(icc) => {
|
||||||
|
ICCS.get_or_insert_with(icc.as_slice(), || color::to_srgb(icc).ok_or(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pub mod color;
|
||||||
|
pub mod convert;
|
||||||
|
pub mod image;
|
||||||
|
pub mod jxl;
|
||||||
|
mod resize;
|
||||||
|
pub mod rotate;
|
||||||
|
pub mod thumbhash;
|
||||||
|
|
||||||
|
pub fn version() -> &'static str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
use std::fmt::Debug;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use pic_scale::{
|
||||||
|
ImageSize, ImageStore, ImageStoreMut, Resampling, ResamplingFunction, Scaler, WorkloadStrategy,
|
||||||
|
};
|
||||||
|
use quick_cache::sync::Cache;
|
||||||
|
|
||||||
|
use crate::image::Pixels;
|
||||||
|
|
||||||
|
/// Reuse the resampling plan for a given src-dst image size pair.
|
||||||
|
type Plans<T> = Cache<[usize; 4], Arc<Resampling<T, 4>>>;
|
||||||
|
|
||||||
|
const PLANS: usize = 16;
|
||||||
|
|
||||||
|
pub(crate) trait Lanczos: Copy + Default + Debug + 'static {
|
||||||
|
fn plan(from: ImageSize, to: ImageSize) -> Option<Arc<Resampling<Self, 4>>>;
|
||||||
|
|
||||||
|
fn cache() -> &'static Plans<Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lanczos for u8 {
|
||||||
|
fn plan(from: ImageSize, to: ImageSize) -> Option<Arc<Resampling<Self, 4>>> {
|
||||||
|
// The 8-bit speed path brightens by up to two levels and is barely faster.
|
||||||
|
Scaler::new(ResamplingFunction::Lanczos3)
|
||||||
|
.set_workload_strategy(WorkloadStrategy::PreferQuality)
|
||||||
|
.plan_rgba_resampling(from, to, false)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache() -> &'static Plans<Self> {
|
||||||
|
static CACHE: LazyLock<Plans<u8>> = LazyLock::new(|| Cache::new(PLANS));
|
||||||
|
&CACHE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lanczos for u16 {
|
||||||
|
fn plan(from: ImageSize, to: ImageSize) -> Option<Arc<Resampling<Self, 4>>> {
|
||||||
|
Scaler::new(ResamplingFunction::Lanczos3)
|
||||||
|
.plan_rgba_resampling16(from, to, false, 16)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache() -> &'static Plans<Self> {
|
||||||
|
static CACHE: LazyLock<Plans<u16>> = LazyLock::new(|| Cache::new(PLANS));
|
||||||
|
&CACHE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size of `size` scaled down to cover `target` and to fit within `max_pixel_size`, if that
|
||||||
|
/// is smaller.
|
||||||
|
pub(crate) fn cover(
|
||||||
|
(w, h): (usize, usize),
|
||||||
|
target: Option<(usize, usize)>,
|
||||||
|
max_pixel_size: usize,
|
||||||
|
) -> Option<(usize, usize)> {
|
||||||
|
// Scales as side ratios, so the side that sets the scale lands exactly on its limit.
|
||||||
|
let fit = (max_pixel_size, w.max(h));
|
||||||
|
let (num, den) = match target {
|
||||||
|
Some((tw, th)) => {
|
||||||
|
let cover = if tw * h >= th * w { (tw, w) } else { (th, h) };
|
||||||
|
if cover.0 * fit.1 < fit.0 * cover.1 {
|
||||||
|
cover
|
||||||
|
} else {
|
||||||
|
fit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => fit,
|
||||||
|
};
|
||||||
|
(num < den).then(|| ((w * num).div_ceil(den), (h * num).div_ceil(den)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resamples `src` into `dst` with Lanczos3.
|
||||||
|
pub(crate) fn lanczos<T: Lanczos>(src: &Pixels<T>, dst: &mut Pixels<T>) -> Option<()> {
|
||||||
|
let [w, h, tw, th] = [src.width(), src.height(), dst.width(), dst.height()];
|
||||||
|
let from = ImageStore::borrow(src.pixels().as_flattened(), w, h).ok()?;
|
||||||
|
let mut to = ImageStoreMut::borrow(dst.pixels_mut().as_flattened_mut(), tw, th).ok()?;
|
||||||
|
to.bit_depth = 8 * size_of::<T>();
|
||||||
|
T::cache()
|
||||||
|
.get_or_insert_with(&[w, h, tw, th], || {
|
||||||
|
T::plan(from.size(), to.size()).ok_or(())
|
||||||
|
})
|
||||||
|
.ok()?
|
||||||
|
.resample(&from, &mut to)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn covers_within_max_pixel_size() {
|
||||||
|
// A 720p thumbnail for a 266 px tile and for a larger target, and panoramas past the limit.
|
||||||
|
assert_eq!(cover((960, 720), Some((266, 266)), 16384), Some((355, 266)));
|
||||||
|
assert_eq!(cover((960, 720), Some((1000, 1000)), 16384), None);
|
||||||
|
assert_eq!(cover((40000, 1000), None, 16384), Some((16384, 410)));
|
||||||
|
assert_eq!(
|
||||||
|
cover((1000, 40000), Some((20000, 20000)), 16384),
|
||||||
|
Some((410, 16384))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum Orientation {
|
||||||
|
Normal,
|
||||||
|
FlipHorizontal,
|
||||||
|
Rotate180,
|
||||||
|
FlipVertical,
|
||||||
|
Transpose,
|
||||||
|
Rotate90,
|
||||||
|
Transverse,
|
||||||
|
Rotate270,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i32> for Orientation {
|
||||||
|
fn from(value: i32) -> Self {
|
||||||
|
match value {
|
||||||
|
2 => Self::FlipHorizontal,
|
||||||
|
3 => Self::Rotate180,
|
||||||
|
4 => Self::FlipVertical,
|
||||||
|
5 => Self::Transpose,
|
||||||
|
6 => Self::Rotate90,
|
||||||
|
7 => Self::Transverse,
|
||||||
|
8 => Self::Rotate270,
|
||||||
|
_ => Self::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Orientation {
|
||||||
|
pub fn dimensions(self, w: usize, h: usize) -> (usize, usize) {
|
||||||
|
match self {
|
||||||
|
Self::Transpose | Self::Rotate90 | Self::Transverse | Self::Rotate270 => (h, w),
|
||||||
|
_ => (w, h),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies `h` rows of `w` pixels, `stride` pixels apart in `src`, into `dst` with `orientation`
|
||||||
|
/// applied. `dst` is dense: `orientation.dimensions(w, h)` pixels.
|
||||||
|
pub fn rotate(
|
||||||
|
src: &[u32],
|
||||||
|
stride: usize,
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
orientation: Orientation,
|
||||||
|
dst: &mut [u32],
|
||||||
|
) {
|
||||||
|
assert!(w <= stride && h.saturating_mul(stride) <= src.len() && w * h <= dst.len());
|
||||||
|
let (dw, _) = orientation.dimensions(w, h);
|
||||||
|
let (w, h, dw) = (w as isize, h as isize, dw as isize);
|
||||||
|
// A source pixel (x, y) lands at base + x * step_x + y * step_y in the dense dw-wide output,
|
||||||
|
// the same layout as Bitmap.createBitmap(src, matrixForExifOrientation(orientation)).
|
||||||
|
let (base, step_x, step_y) = match orientation {
|
||||||
|
Orientation::FlipHorizontal => (w - 1, -1, dw),
|
||||||
|
Orientation::Rotate180 => ((h - 1) * dw + w - 1, -1, -dw),
|
||||||
|
Orientation::FlipVertical => ((h - 1) * dw, 1, -dw),
|
||||||
|
Orientation::Transpose => (0, dw, 1),
|
||||||
|
Orientation::Rotate90 => (h - 1, dw, -1),
|
||||||
|
Orientation::Transverse => ((w - 1) * dw + h - 1, -dw, -1),
|
||||||
|
Orientation::Rotate270 => ((w - 1) * dw, -dw, 1),
|
||||||
|
Orientation::Normal => (0, 1, dw),
|
||||||
|
};
|
||||||
|
let (src, dst) = (src.as_ptr(), dst.as_mut_ptr());
|
||||||
|
// 32x32 u32 tiles are 4KB, so the scattered writes of a 90/270 transpose stay in L1.
|
||||||
|
for ty in (0..h).step_by(32) {
|
||||||
|
for tx in (0..w).step_by(32) {
|
||||||
|
for y in ty..(ty + 32).min(h) {
|
||||||
|
// SAFETY: src holds h rows of stride pixels (asserted above).
|
||||||
|
let row = unsafe { src.add(y as usize * stride) };
|
||||||
|
let mut idx = base + y * step_y + tx * step_x;
|
||||||
|
for x in tx..(tx + 32).min(w) {
|
||||||
|
// SAFETY: the affine mapping stays within the w*h output and x stays within its row.
|
||||||
|
unsafe { dst.add(idx as usize).write(row.add(x as usize).read()) };
|
||||||
|
idx += step_x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright (c) 2023 Evan Wallace
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
pub fn decode(hash: &[u8], rgba: &mut [u8; 32 * 32 * 4]) -> Option<(u32, u32)> {
|
||||||
|
// Read the constants
|
||||||
|
let &[h0, h1, h2, h3, h4] = hash.first_chunk()?;
|
||||||
|
let header24 = u32::from(h0) | u32::from(h1) << 8 | u32::from(h2) << 16;
|
||||||
|
let header16 = u16::from_le_bytes([h3, h4]);
|
||||||
|
let l_dc = (header24 & 63) as f32 / 63.0;
|
||||||
|
let p_dc = ((header24 >> 6) & 63) as f32 / 31.5 - 1.0;
|
||||||
|
let q_dc = ((header24 >> 12) & 63) as f32 / 31.5 - 1.0;
|
||||||
|
let has_alpha = header24 >> 23 != 0;
|
||||||
|
let is_landscape = header16 >> 15 != 0;
|
||||||
|
let long = if has_alpha { 5 } else { 7 };
|
||||||
|
let short = usize::from(header16 & 7);
|
||||||
|
if short == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lx = (if is_landscape { long } else { short }).max(3);
|
||||||
|
let ly = (if is_landscape { short } else { long }).max(3);
|
||||||
|
let alpha = if has_alpha { *hash.get(5)? } else { 15 };
|
||||||
|
let a_dc = (alpha & 15) as f32 / 15.0;
|
||||||
|
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||||
|
let ac_start = if has_alpha { 6 } else { 5 };
|
||||||
|
let mut ac_index = 0;
|
||||||
|
let mut channel = |nx, ny, scale| -> Option<[f32; 49]> {
|
||||||
|
let mut ac = [0.0; 49];
|
||||||
|
let mut j = 0;
|
||||||
|
for cy in 0..ny {
|
||||||
|
for _ in (usize::from(cy == 0)..nx).take_while(|cx| cx * ny < nx * (ny - cy)) {
|
||||||
|
let value = (hash.get(ac_start + (ac_index >> 1))? >> ((ac_index & 1) * 4)) & 15;
|
||||||
|
ac[j] = (value as f32 / 7.5 - 1.0) * scale;
|
||||||
|
ac_index += 1;
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ac)
|
||||||
|
};
|
||||||
|
let l_ac = channel(lx, ly, ((header24 >> 18) & 31) as f32 / 31.0)?;
|
||||||
|
let p_ac = channel(3, 3, ((header16 >> 3) & 63) as f32 / 63.0 * 1.25)?;
|
||||||
|
let q_ac = channel(3, 3, ((header16 >> 9) & 63) as f32 / 63.0 * 1.25)?;
|
||||||
|
let a_ac = if has_alpha {
|
||||||
|
channel(5, 5, (alpha >> 4) as f32 / 15.0)?
|
||||||
|
} else {
|
||||||
|
[0.0; 49]
|
||||||
|
};
|
||||||
|
// Decode using the DCT into RGB
|
||||||
|
let ratio = if is_landscape {
|
||||||
|
long as f32 / short as f32
|
||||||
|
} else {
|
||||||
|
short as f32 / long as f32
|
||||||
|
};
|
||||||
|
let w = (if ratio > 1.0 { 32.0 } else { 32.0 * ratio }).round() as u32;
|
||||||
|
let h = (if ratio > 1.0 { 32.0 / ratio } else { 32.0 }).round() as u32;
|
||||||
|
let channels = [(&l_ac, lx, ly), (&p_ac, 3, 3), (&q_ac, 3, 3), (&a_ac, 5, 5)];
|
||||||
|
let count = if has_alpha { 4 } else { 3 };
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
// Precompute the coefficients
|
||||||
|
let fx: [f32; 7] =
|
||||||
|
std::array::from_fn(|cx| (PI / w as f32 * (x as f32 + 0.5) * cx as f32).cos());
|
||||||
|
let fy: [f32; 7] =
|
||||||
|
std::array::from_fn(|cy| (PI / h as f32 * (y as f32 + 0.5) * cy as f32).cos());
|
||||||
|
let mut lpqa = [l_dc, p_dc, q_dc, a_dc];
|
||||||
|
for (sum, &(ac, nx, ny)) in lpqa.iter_mut().zip(&channels[..count]) {
|
||||||
|
let mut j = 0;
|
||||||
|
for (cy, fy) in fy.iter().enumerate().take(ny) {
|
||||||
|
let fy2 = fy * 2.0;
|
||||||
|
for cx in (usize::from(cy == 0)..nx).take_while(|cx| cx * ny < nx * (ny - cy)) {
|
||||||
|
*sum += ac[j] * fx[cx] * fy2;
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Convert to RGB
|
||||||
|
let [l, p, q, a] = lpqa;
|
||||||
|
let b = l - 2.0 / 3.0 * p;
|
||||||
|
let r = (3.0 * l - b + q) / 2.0;
|
||||||
|
let g = r - q;
|
||||||
|
let i = ((y * w + x) * 4) as usize;
|
||||||
|
for (dst, value) in rgba[i..i + 4].iter_mut().zip([r, g, b, a]) {
|
||||||
|
*dst = (255.0 * value.min(1.0)).round().max(0.0) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((w, h))
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use native_core::convert::convert_1010102;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channels_and_alpha() {
|
||||||
|
for (a, byte) in [(0, 0), (1, 85), (2, 170), (3, 255)] {
|
||||||
|
let src: [u32; 3] = [
|
||||||
|
(0x0ff << 10) | (0x3ff << 20) | (a << 30),
|
||||||
|
0x0ff | (0x3ff << 10) | (a << 30),
|
||||||
|
0x3ff | (0x0ff << 20) | (a << 30),
|
||||||
|
];
|
||||||
|
let mut dst = [0; 3];
|
||||||
|
convert_1010102(&src, 3, 3, 1, &mut dst);
|
||||||
|
assert_eq!(
|
||||||
|
dst,
|
||||||
|
[
|
||||||
|
0xff4000 | (byte << 24),
|
||||||
|
0x00ff40 | (byte << 24),
|
||||||
|
0x4000ff | (byte << 24)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn padded_rows() {
|
||||||
|
let src: [u32; 6] = [
|
||||||
|
0xc00003ff, 0xc00ffc00, 0xdeadbeef, 0xfff00000, 0xffffffff, 0xdeadbeef,
|
||||||
|
];
|
||||||
|
let mut dst = [0; 4];
|
||||||
|
convert_1010102(&src, 3, 2, 2, &mut dst);
|
||||||
|
assert_eq!(dst, [0xff0000ff, 0xff00ff00, 0xffff0000, 0xffffffff]);
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,230 @@
|
|||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
|
use native_core::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
|
||||||
|
use native_core::image::{Image, Pixels, Samples};
|
||||||
|
use native_core::jxl::Jxl;
|
||||||
|
|
||||||
|
fn decode(data: &[u8], target: Target) -> Option<Image> {
|
||||||
|
Jxl::open(data, target)?.decode(None, 16384, &AtomicBool::new(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_srgb(data: &[u8]) -> Option<Pixels<u8>> {
|
||||||
|
let Image {
|
||||||
|
cicp:
|
||||||
|
CicpProfile {
|
||||||
|
color_primaries: CicpColorPrimaries::Bt709,
|
||||||
|
transfer_characteristics: TransferCharacteristics::Srgb,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
samples: Samples::U8(rgba),
|
||||||
|
} = decode(data, Target::Srgb)?
|
||||||
|
else {
|
||||||
|
panic!("not decoded to sRGB");
|
||||||
|
};
|
||||||
|
Some(rgba)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_to_premultiplied_srgb() {
|
||||||
|
let cases: [(&str, &[u8], [u8; 8]); 5] = [
|
||||||
|
(
|
||||||
|
"sRGB with alpha",
|
||||||
|
include_bytes!("fixtures/srgb_alpha.jxl"),
|
||||||
|
[128, 0, 0, 128, 0, 0, 255, 255],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Display P3",
|
||||||
|
include_bytes!("fixtures/p3.jxl"),
|
||||||
|
[255, 0, 0, 255, 255, 255, 255, 255],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"grayscale",
|
||||||
|
include_bytes!("fixtures/gray.jxl"),
|
||||||
|
[0, 0, 0, 255, 128, 128, 128, 255],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"grayscale with ICC profile",
|
||||||
|
include_bytes!("fixtures/gray_icc.jxl"),
|
||||||
|
[81, 81, 81, 255, 203, 203, 203, 255],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"animation",
|
||||||
|
include_bytes!("fixtures/animated.jxl"),
|
||||||
|
[255, 0, 0, 255, 255, 0, 0, 255],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (name, data, expected) in cases {
|
||||||
|
let rgba = decode_srgb(data).expect(name);
|
||||||
|
assert_eq!(
|
||||||
|
(rgba.width(), rgba.height(), rgba.pixels().as_flattened()),
|
||||||
|
(2, 1, &expected[..]),
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_lossy_icc_to_srgb() {
|
||||||
|
// jxl-rs renders XYB straight to sRGB, while a JPEG transcode decodes in its ICC space and
|
||||||
|
// takes our transform.
|
||||||
|
for data in [
|
||||||
|
&include_bytes!("fixtures/xyb_icc.jxl")[..],
|
||||||
|
include_bytes!("fixtures/jpeg_icc.jxl"),
|
||||||
|
] {
|
||||||
|
let rgba = decode_srgb(data).unwrap();
|
||||||
|
for px in rgba.pixels() {
|
||||||
|
assert!(
|
||||||
|
px.iter()
|
||||||
|
.zip([210, 119, 65, 255])
|
||||||
|
.all(|(&a, b)| a.abs_diff(b) <= 3),
|
||||||
|
"{px:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_decodes_keep_profile_colors_when_transforms_are_reused() {
|
||||||
|
let cases: [(&[u8], [u8; 8]); 3] = [
|
||||||
|
(
|
||||||
|
include_bytes!("fixtures/p3.jxl"),
|
||||||
|
[255, 0, 0, 255, 255, 255, 255, 255],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
include_bytes!("fixtures/gray_icc.jxl"),
|
||||||
|
[81, 81, 81, 255, 203, 203, 203, 255],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
include_bytes!("fixtures/srgb_alpha.jxl"),
|
||||||
|
[128, 0, 0, 128, 0, 0, 255, 255],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
for _ in 0..4 {
|
||||||
|
scope.spawn(|| {
|
||||||
|
for _ in 0..8 {
|
||||||
|
for (data, expected) in cases {
|
||||||
|
let rgba = decode_srgb(data).unwrap();
|
||||||
|
assert_eq!(rgba.pixels().as_flattened(), &expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tone_maps_hdr() {
|
||||||
|
let rgba = decode_srgb(include_bytes!("fixtures/pq_grays.jxl")).unwrap();
|
||||||
|
let levels: Vec<u8> = rgba
|
||||||
|
.pixels()
|
||||||
|
.iter()
|
||||||
|
.map(|px| {
|
||||||
|
assert!(
|
||||||
|
px[..3].iter().max().unwrap() - px[..3].iter().min().unwrap() <= 4,
|
||||||
|
"{px:?} is not neutral"
|
||||||
|
);
|
||||||
|
px[1]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
levels.is_sorted_by(|a, b| a < b) && levels[2] < 255,
|
||||||
|
"{levels:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_spaces_that_cicp_describes() {
|
||||||
|
let pq = decode(include_bytes!("fixtures/pq_alpha.jxl"), Target::Source);
|
||||||
|
let Some(Image {
|
||||||
|
cicp:
|
||||||
|
CicpProfile {
|
||||||
|
color_primaries: CicpColorPrimaries::Bt2020,
|
||||||
|
transfer_characteristics: TransferCharacteristics::Smpte2084,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
samples: Samples::U16(rgba),
|
||||||
|
}) = pq
|
||||||
|
else {
|
||||||
|
panic!("not decoded to BT.2100 PQ");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
rgba.pixels(),
|
||||||
|
[[20000, 15000, 10000, 32768], [49271, 49271, 49271, 65535]]
|
||||||
|
);
|
||||||
|
let p3 = decode(include_bytes!("fixtures/p3.jxl"), Target::Source);
|
||||||
|
let Some(Image {
|
||||||
|
cicp:
|
||||||
|
CicpProfile {
|
||||||
|
color_primaries: CicpColorPrimaries::Smpte432,
|
||||||
|
transfer_characteristics: TransferCharacteristics::Srgb,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
samples: Samples::U8(rgba),
|
||||||
|
}) = p3
|
||||||
|
else {
|
||||||
|
panic!("not decoded to Display P3");
|
||||||
|
};
|
||||||
|
assert_eq!(rgba.pixels(), [[255, 0, 0, 255], [255, 255, 255, 255]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_other_spaces_to_srgb() {
|
||||||
|
let data = include_bytes!("fixtures/gray_icc.jxl");
|
||||||
|
let Some(Image {
|
||||||
|
cicp:
|
||||||
|
CicpProfile {
|
||||||
|
color_primaries: CicpColorPrimaries::Bt709,
|
||||||
|
transfer_characteristics: TransferCharacteristics::Srgb,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
samples: Samples::U8(rgba),
|
||||||
|
}) = decode(data, Target::Source)
|
||||||
|
else {
|
||||||
|
panic!("not decoded to sRGB");
|
||||||
|
};
|
||||||
|
assert_eq!(rgba.pixels(), decode_srgb(data).unwrap().pixels());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downscales_to_cover() {
|
||||||
|
let data = include_bytes!("fixtures/jpeg_icc.jxl");
|
||||||
|
let Some(Image {
|
||||||
|
samples: Samples::U8(rgba),
|
||||||
|
..
|
||||||
|
}) =
|
||||||
|
Jxl::open(data, Target::Srgb)
|
||||||
|
.unwrap()
|
||||||
|
.decode(Some((8, 4)), 16384, &AtomicBool::new(false))
|
||||||
|
else {
|
||||||
|
panic!("not decoded to 8 bits");
|
||||||
|
};
|
||||||
|
// 16x16 covers 8x4 at 8x8, read through the wide decode buffer.
|
||||||
|
assert_eq!((rgba.width(), rgba.height()), (8, 8));
|
||||||
|
for px in rgba.pixels() {
|
||||||
|
assert!(
|
||||||
|
px.iter()
|
||||||
|
.zip([210, 119, 65, 255])
|
||||||
|
.all(|(&a, b)| a.abs_diff(b) <= 3),
|
||||||
|
"{px:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_truncated_input() {
|
||||||
|
let data = include_bytes!("fixtures/srgb_alpha.jxl");
|
||||||
|
assert!(decode_srgb(&data[..data.len() / 2]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stops_when_cancelled() {
|
||||||
|
let data = include_bytes!("fixtures/large.jxl");
|
||||||
|
let decode = |cancelled| {
|
||||||
|
Jxl::open(data, Target::Srgb)
|
||||||
|
.unwrap()
|
||||||
|
.decode(None, 16384, &AtomicBool::new(cancelled))
|
||||||
|
.is_some()
|
||||||
|
};
|
||||||
|
assert_eq!((decode(false), decode(true)), (true, false));
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use native_core::rotate::{Orientation, rotate};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exif_orientations() {
|
||||||
|
let src = [1u32, 2, 3, 4, 5, 6];
|
||||||
|
let cases = [
|
||||||
|
(1, (3, 2), [1, 2, 3, 4, 5, 6]),
|
||||||
|
(2, (3, 2), [3, 2, 1, 6, 5, 4]),
|
||||||
|
(3, (3, 2), [6, 5, 4, 3, 2, 1]),
|
||||||
|
(4, (3, 2), [4, 5, 6, 1, 2, 3]),
|
||||||
|
(5, (2, 3), [1, 4, 2, 5, 3, 6]),
|
||||||
|
(6, (2, 3), [4, 1, 5, 2, 6, 3]),
|
||||||
|
(7, (2, 3), [6, 3, 5, 2, 4, 1]),
|
||||||
|
(8, (2, 3), [3, 6, 2, 5, 1, 4]),
|
||||||
|
(9, (3, 2), [1, 2, 3, 4, 5, 6]),
|
||||||
|
];
|
||||||
|
for (exif, size, expected) in cases {
|
||||||
|
let orientation = Orientation::from(exif);
|
||||||
|
let mut dst = [0; 6];
|
||||||
|
rotate(&src, 3, 3, 2, orientation, &mut dst);
|
||||||
|
assert_eq!(orientation.dimensions(3, 2), size);
|
||||||
|
assert_eq!(dst, expected, "orientation {exif}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn padded_rows() {
|
||||||
|
let src = [1u32, 2, 3, 99, 4, 5, 6, 99];
|
||||||
|
let mut dst = [0; 6];
|
||||||
|
rotate(&src, 4, 3, 2, Orientation::Rotate90, &mut dst);
|
||||||
|
assert_eq!(dst, [4, 1, 5, 2, 6, 3]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
use native_core::thumbhash::decode;
|
||||||
|
|
||||||
|
// Expected pixels come from the old Swift decoder patched to round like the Java did (it truncated),
|
||||||
|
// taken at pixels whose values sit well away from a rounding boundary, so the check is exact on any libm.
|
||||||
|
const LANDSCAPE: &[u8] = &[
|
||||||
|
0xdc, 0xf7, 0x0d, 0x35, 0x84, 0x85, 0x79, 0x78, 0x7f, 0x77, 0x77, 0xa5, 0x77, 0x48, 0x87, 0x66,
|
||||||
|
0x86, 0x60, 0x57, 0x08, 0x76,
|
||||||
|
];
|
||||||
|
const PORTRAIT: &[u8] = &[
|
||||||
|
0x93, 0x4a, 0x06, 0x2d, 0x06, 0x92, 0x56, 0xc3, 0x74, 0x05, 0x58, 0x67, 0xda, 0x8a, 0xb6, 0x67,
|
||||||
|
0x94, 0x90, 0x51, 0x07, 0x19,
|
||||||
|
];
|
||||||
|
const ALPHA: &[u8] = &[
|
||||||
|
0x60, 0x9a, 0x86, 0x3d, 0x0c, 0x3b, 0xb0, 0x59, 0x6c, 0x96, 0xa8, 0x45, 0x69, 0xf4, 0x84, 0xf9,
|
||||||
|
0x0e, 0xa8, 0x27, 0x58, 0x76, 0x88, 0x70, 0x76, 0x47,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn check(name: &str, hash: &[u8], size: (u32, u32), samples: &[(u32, u32, [u8; 4])]) {
|
||||||
|
let mut rgba = [0; 32 * 32 * 4];
|
||||||
|
let (width, height) = decode(hash, &mut rgba).unwrap();
|
||||||
|
assert_eq!((width, height), size, "{name}");
|
||||||
|
for &(x, y, expected) in samples {
|
||||||
|
let i = ((y * width + x) * 4) as usize;
|
||||||
|
assert_eq!(rgba[i..i + 4], expected, "{name} pixel {x},{y}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_swift_placeholders() {
|
||||||
|
check(
|
||||||
|
"landscape",
|
||||||
|
LANDSCAPE,
|
||||||
|
(32, 23),
|
||||||
|
&[
|
||||||
|
(0, 0, [48, 83, 154, 255]),
|
||||||
|
(11, 0, [59, 90, 156, 255]),
|
||||||
|
(20, 0, [91, 113, 163, 255]),
|
||||||
|
(29, 1, [109, 123, 156, 255]),
|
||||||
|
(1, 11, [84, 92, 96, 255]),
|
||||||
|
(10, 11, [77, 84, 89, 255]),
|
||||||
|
(20, 11, [143, 144, 141, 255]),
|
||||||
|
(30, 10, [164, 161, 153, 255]),
|
||||||
|
(1, 22, [98, 84, 35, 255]),
|
||||||
|
(9, 21, [69, 56, 12, 255]),
|
||||||
|
(19, 22, [87, 72, 30, 255]),
|
||||||
|
(30, 22, [79, 60, 13, 255]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
"portrait",
|
||||||
|
PORTRAIT,
|
||||||
|
(23, 32),
|
||||||
|
&[
|
||||||
|
(0, 0, [27, 53, 53, 255]),
|
||||||
|
(5, 0, [57, 80, 87, 255]),
|
||||||
|
(14, 1, [41, 57, 57, 255]),
|
||||||
|
(22, 0, [42, 60, 37, 255]),
|
||||||
|
(0, 15, [149, 100, 11, 255]),
|
||||||
|
(7, 15, [166, 109, 28, 255]),
|
||||||
|
(14, 15, [181, 114, 19, 255]),
|
||||||
|
(22, 15, [204, 135, 10, 255]),
|
||||||
|
(0, 30, [70, 47, 0, 255]),
|
||||||
|
(7, 30, [106, 72, 18, 255]),
|
||||||
|
(13, 31, [135, 88, 19, 255]),
|
||||||
|
(22, 31, [159, 104, 0, 255]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
check(
|
||||||
|
"alpha",
|
||||||
|
ALPHA,
|
||||||
|
(32, 32),
|
||||||
|
&[
|
||||||
|
(1, 0, [227, 76, 54, 0]),
|
||||||
|
(10, 0, [147, 102, 114, 45]),
|
||||||
|
(19, 0, [130, 156, 130, 94]),
|
||||||
|
(31, 0, [192, 168, 23, 0]),
|
||||||
|
(0, 15, [232, 65, 49, 162]),
|
||||||
|
(10, 14, [142, 87, 106, 255]),
|
||||||
|
(20, 14, [141, 152, 119, 255]),
|
||||||
|
(31, 14, [231, 191, 50, 148]),
|
||||||
|
(0, 31, [255, 33, 10, 0]),
|
||||||
|
(11, 31, [213, 65, 76, 137]),
|
||||||
|
(20, 31, [209, 109, 64, 137]),
|
||||||
|
(31, 31, [255, 137, 0, 0]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_truncated_hash() {
|
||||||
|
let mut rgba = [0; 32 * 32 * 4];
|
||||||
|
for len in 0..ALPHA.len() {
|
||||||
|
assert!(decode(&ALPHA[..len], &mut rgba).is_none(), "length {len}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "native_core_ffi"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
# native_toolchain_rust wants the explicit name and both crate types.
|
||||||
|
[lib]
|
||||||
|
name = "native_core_ffi"
|
||||||
|
crate-type = ["cdylib", "staticlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
native_core = { path = "../native_core" }
|
||||||
|
libc = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = { workspace = true }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
cbindgen = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Files that define exported items. A directory here would make Flutter rerun the hook every build.
|
||||||
|
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||||
|
println!("cargo:rerun-if-changed=src/jxl.rs");
|
||||||
|
println!("cargo:rerun-if-changed=src/thumbhash.rs");
|
||||||
|
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||||
|
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||||
|
cbindgen::generate(&crate_dir)
|
||||||
|
.unwrap_or_else(|e| panic!("cbindgen failed: {e}"))
|
||||||
|
.write_to_file(Path::new(&crate_dir).join("include/native_core.h"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
language = "C"
|
||||||
|
pragma_once = true
|
||||||
|
autogen_warning = "// Generated by cbindgen. Do not edit."
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Keep the channel in sync with the rust entries in native/mise.toml and mobile/mise.toml.
|
||||||
|
[toolchain]
|
||||||
|
channel = "1.99.1"
|
||||||
|
targets = [
|
||||||
|
"aarch64-linux-android",
|
||||||
|
"armv7-linux-androideabi",
|
||||||
|
"x86_64-linux-android",
|
||||||
|
"aarch64-apple-ios",
|
||||||
|
"aarch64-apple-ios-sim",
|
||||||
|
"x86_64-apple-ios",
|
||||||
|
"aarch64-apple-darwin",
|
||||||
|
"x86_64-apple-darwin",
|
||||||
|
"aarch64-unknown-linux-gnu",
|
||||||
|
"x86_64-unknown-linux-gnu",
|
||||||
|
"aarch64-pc-windows-msvc",
|
||||||
|
"x86_64-pc-windows-msvc",
|
||||||
|
]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
use jni::EnvUnowned;
|
||||||
|
use jni::errors::ThrowRuntimeExAndDefault;
|
||||||
|
use jni::objects::{JByteBuffer, JClass, JObject};
|
||||||
|
use jni::sys::{jint, jlong, jobject};
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_allocate<'caller>(
|
||||||
|
_env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
size: jint,
|
||||||
|
) -> jlong {
|
||||||
|
// SAFETY: malloc accepts any size.
|
||||||
|
unsafe { libc::malloc(size as usize) as jlong }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_free<'caller>(
|
||||||
|
_env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
address: jlong,
|
||||||
|
) {
|
||||||
|
// SAFETY: the caller passes null or a live C allocation it owns.
|
||||||
|
unsafe { libc::free(address as *mut _) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_realloc<'caller>(
|
||||||
|
_env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
address: jlong,
|
||||||
|
size: jint,
|
||||||
|
) -> jlong {
|
||||||
|
// SAFETY: the caller passes null or a live C allocation it owns.
|
||||||
|
unsafe { libc::realloc(address as *mut _, size as usize) as jlong }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_wrap<'caller>(
|
||||||
|
env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
address: jlong,
|
||||||
|
capacity: jint,
|
||||||
|
) -> jobject {
|
||||||
|
// jni's new_direct_byte_buffer rejects null and clears the java exception, the C did neither.
|
||||||
|
let raw = env.as_raw();
|
||||||
|
// SAFETY: raw is the VM's JNIEnv and android JNI is at least 1.6, so the 1.4 table exists.
|
||||||
|
unsafe { ((**raw).v1_4.NewDirectByteBuffer)(raw, address as *mut _, capacity as jlong) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_copy<'caller>(
|
||||||
|
mut env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
buffer: JByteBuffer<'caller>,
|
||||||
|
dest: jlong,
|
||||||
|
offset: jint,
|
||||||
|
length: jint,
|
||||||
|
) {
|
||||||
|
env.with_env(|env| -> jni::errors::Result<()> {
|
||||||
|
if let Ok(src) = env.get_direct_buffer_address(&buffer) {
|
||||||
|
// SAFETY: the caller supplies valid, non-overlapping ranges for the copy.
|
||||||
|
unsafe {
|
||||||
|
let src = src.offset(offset as isize).cast();
|
||||||
|
libc::memcpy(dest as *mut _, src, length as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.resolve::<ThrowRuntimeExAndDefault>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_createGlobalRef<'caller>(
|
||||||
|
mut env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
obj: JObject<'caller>,
|
||||||
|
) -> jlong {
|
||||||
|
env.with_env(|env| -> jni::errors::Result<_> {
|
||||||
|
// Transfer the global reference to the caller without deleting it.
|
||||||
|
Ok(env.new_global_ref(obj).map_or(0, |r| r.into_raw() as jlong))
|
||||||
|
})
|
||||||
|
.resolve::<ThrowRuntimeExAndDefault>()
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use std::ffi::c_void;
|
||||||
|
use std::mem::MaybeUninit;
|
||||||
|
use std::{ptr, slice};
|
||||||
|
|
||||||
|
use jni::EnvUnowned;
|
||||||
|
use jni::errors::ThrowRuntimeExAndDefault;
|
||||||
|
use jni::objects::{JIntArray, JObject};
|
||||||
|
use jni::sys::{JNIEnv, jlong, jobject};
|
||||||
|
|
||||||
|
pub const FORMAT_RGBA_8888: i32 = 1;
|
||||||
|
pub const FORMAT_RGBA_1010102: i32 = 10;
|
||||||
|
|
||||||
|
const RESULT_SUCCESS: i32 = 0;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct Info {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
stride: u32,
|
||||||
|
format: i32,
|
||||||
|
flags: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[link(name = "jnigraphics")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
#[link_name = "AndroidBitmap_getInfo"]
|
||||||
|
fn get_info(env: *mut JNIEnv, bitmap: jobject, info: *mut Info) -> i32;
|
||||||
|
#[link_name = "AndroidBitmap_lockPixels"]
|
||||||
|
fn lock_pixels(env: *mut JNIEnv, bitmap: jobject, pixels: *mut *mut c_void) -> i32;
|
||||||
|
#[link_name = "AndroidBitmap_unlockPixels"]
|
||||||
|
fn unlock_pixels(env: *mut JNIEnv, bitmap: jobject) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `f(src, stride, width, height, dst)` over the pixels of a `format` bitmap, writing into a
|
||||||
|
/// malloc'd dense buffer of `size(width, height)` pixels that Kotlin frees. Fills `out_info` with
|
||||||
|
/// {width, height, rowBytes} of that buffer. Returns 0 when the bitmap can't be handled.
|
||||||
|
pub fn transform<'caller>(
|
||||||
|
mut env: EnvUnowned<'caller>,
|
||||||
|
bitmap: JObject<'caller>,
|
||||||
|
out_info: JIntArray<'caller>,
|
||||||
|
format: i32,
|
||||||
|
size: impl Fn(usize, usize) -> (usize, usize),
|
||||||
|
f: impl FnOnce(&[u32], usize, usize, usize, &mut [u32]),
|
||||||
|
) -> jlong {
|
||||||
|
let mut dst = ptr::null_mut();
|
||||||
|
let outcome = env.with_env(|env| -> jni::errors::Result<_> {
|
||||||
|
let raw_env = env.get_raw();
|
||||||
|
let mut info = MaybeUninit::uninit();
|
||||||
|
// SAFETY: JNI owns the bitmap reference and info is writable.
|
||||||
|
if unsafe { get_info(raw_env, bitmap.as_raw(), info.as_mut_ptr()) } != RESULT_SUCCESS {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
// SAFETY: get_info initialized info on success.
|
||||||
|
let info = unsafe { info.assume_init() };
|
||||||
|
if info.format != format {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let (w, h) = (info.width as usize, info.height as usize);
|
||||||
|
let stride = info.stride as usize / 4;
|
||||||
|
let (dw, dh) = size(w, h);
|
||||||
|
// SAFETY: malloc accepts the byte size of this valid Android bitmap.
|
||||||
|
dst = unsafe { libc::malloc(dw * dh * 4) };
|
||||||
|
if dst.is_null() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut pixels = ptr::null_mut();
|
||||||
|
// SAFETY: the bitmap reference is valid and pixels is writable.
|
||||||
|
if unsafe { lock_pixels(raw_env, bitmap.as_raw(), &mut pixels) } != RESULT_SUCCESS {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
// SAFETY: a locked 4 byte format bitmap holds h aligned rows of stride pixels, and the
|
||||||
|
// malloc above holds dw * dh pixels.
|
||||||
|
let (src, out) = unsafe {
|
||||||
|
(
|
||||||
|
slice::from_raw_parts(pixels.cast::<u32>(), h * stride),
|
||||||
|
slice::from_raw_parts_mut(dst.cast::<u32>(), dw * dh),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
f(src, stride, w, h, out);
|
||||||
|
// SAFETY: balances the successful lock above.
|
||||||
|
unsafe { unlock_pixels(raw_env, bitmap.as_raw()) };
|
||||||
|
out_info.set_region(env, 0, &[dw as i32, dh as i32, (dw * 4) as i32])?;
|
||||||
|
Ok(dst as jlong)
|
||||||
|
});
|
||||||
|
let result = outcome.resolve::<ThrowRuntimeExAndDefault>();
|
||||||
|
if result == 0 {
|
||||||
|
// SAFETY: dst is null or the malloc allocation that has not been returned to Kotlin.
|
||||||
|
unsafe { libc::free(dst) };
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use jni::EnvUnowned;
|
||||||
|
use jni::objects::{JClass, JIntArray, JObject};
|
||||||
|
use jni::sys::jlong;
|
||||||
|
use native_core::convert::convert_1010102;
|
||||||
|
|
||||||
|
use super::bitmap::{self, FORMAT_RGBA_1010102};
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeImage_convert1010102<'caller>(
|
||||||
|
env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
bitmap: JObject<'caller>,
|
||||||
|
out_info: JIntArray<'caller>,
|
||||||
|
) -> jlong {
|
||||||
|
bitmap::transform(
|
||||||
|
env,
|
||||||
|
bitmap,
|
||||||
|
out_info,
|
||||||
|
FORMAT_RGBA_1010102,
|
||||||
|
|w, h| (w, h),
|
||||||
|
convert_1010102,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::{ptr, slice};
|
||||||
|
|
||||||
|
use libc::size_t;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use jni::{
|
||||||
|
EnvUnowned,
|
||||||
|
errors::ThrowRuntimeExAndDefault,
|
||||||
|
objects::{JByteBuffer, JClass, JIntArray},
|
||||||
|
sys::{jint, jlong},
|
||||||
|
};
|
||||||
|
use native_core::color::Target;
|
||||||
|
use native_core::image::{Image, Samples};
|
||||||
|
use native_core::jxl::Jxl;
|
||||||
|
|
||||||
|
use super::guard;
|
||||||
|
|
||||||
|
/// The size and colour space of decoded pixels.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct NativeCoreImageInfo {
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
/// 8 or 16, for samples in native byte order.
|
||||||
|
pub bit_depth: u8,
|
||||||
|
/// ITU-T H.273 colour primaries.
|
||||||
|
pub primaries: u8,
|
||||||
|
/// ITU-T H.273 transfer characteristics.
|
||||||
|
pub transfer: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a JPEG XL image to premultiplied RGBA in sRGB, or with `keep_space`, in the image's own
|
||||||
|
/// space when it has BT.709, BT.2020 or Display P3 primaries with the sRGB, PQ or HLG transfer.
|
||||||
|
/// Unless either is 0, it is downscaled to cover `target_width` x `target_height`, and it never
|
||||||
|
/// exceeds `max_pixel_size` pixels per side. `info` gets the size and space. The caller frees the
|
||||||
|
/// returned buffer with free(). Returns null on failure, or once `native_core_cancel` was called on
|
||||||
|
/// `cancelled`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `data` must be null or readable for `len` bytes, at most `isize::MAX`.
|
||||||
|
/// `cancelled` must be null or valid for the call and only changed through `native_core_cancel`.
|
||||||
|
/// `info` must be null or a valid writable pointer, outside `data`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn native_core_jxl_decode(
|
||||||
|
data: *const u8,
|
||||||
|
len: size_t,
|
||||||
|
keep_space: bool,
|
||||||
|
target_width: u32,
|
||||||
|
target_height: u32,
|
||||||
|
max_pixel_size: u32,
|
||||||
|
cancelled: *mut bool,
|
||||||
|
info: *mut NativeCoreImageInfo,
|
||||||
|
) -> *mut u8 {
|
||||||
|
guard(ptr::null_mut(), || {
|
||||||
|
if data.is_null() || cancelled.is_null() || info.is_null() {
|
||||||
|
return ptr::null_mut();
|
||||||
|
}
|
||||||
|
// SAFETY: the caller keeps the flag alive for the call and only changes it atomically.
|
||||||
|
let cancelled = unsafe { AtomicBool::from_ptr(cancelled) };
|
||||||
|
// SAFETY: the caller provides a buffer readable for len bytes.
|
||||||
|
let input = unsafe { slice::from_raw_parts(data, len) };
|
||||||
|
let target = if keep_space {
|
||||||
|
Target::Source
|
||||||
|
} else {
|
||||||
|
Target::Srgb
|
||||||
|
};
|
||||||
|
let cover = (target_width > 0 && target_height > 0)
|
||||||
|
.then_some((target_width as usize, target_height as usize));
|
||||||
|
let Some(Image { cicp, samples }) = Jxl::open(input, target)
|
||||||
|
.and_then(|image| image.decode(cover, max_pixel_size as usize, cancelled))
|
||||||
|
else {
|
||||||
|
return ptr::null_mut();
|
||||||
|
};
|
||||||
|
let (width, height, bit_depth, pixels) = match samples {
|
||||||
|
Samples::U8(rgba) => (rgba.width(), rgba.height(), 8, rgba.into_raw()),
|
||||||
|
Samples::U16(rgba) => (rgba.width(), rgba.height(), 16, rgba.into_raw()),
|
||||||
|
};
|
||||||
|
// SAFETY: the output is writable.
|
||||||
|
unsafe {
|
||||||
|
*info = NativeCoreImageInfo {
|
||||||
|
width: width as i32,
|
||||||
|
height: height as i32,
|
||||||
|
bit_depth,
|
||||||
|
primaries: cicp.color_primaries as u8,
|
||||||
|
transfer: cicp.transfer_characteristics as u8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pixels
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the decode given `flag` to stop early.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `flag` must be null or valid and otherwise only read by `native_core_jxl_decode`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
|
||||||
|
if !flag.is_null() {
|
||||||
|
// SAFETY: the caller guarantees the flag is valid and only accessed atomically.
|
||||||
|
unsafe { AtomicBool::from_ptr(flag) }.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeImage_decodeJxl<'caller>(
|
||||||
|
mut env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
data: jlong,
|
||||||
|
length: jint,
|
||||||
|
target_width: jint,
|
||||||
|
target_height: jint,
|
||||||
|
max_pixel_size: jint,
|
||||||
|
cancelled: JByteBuffer<'caller>,
|
||||||
|
info: JIntArray<'caller>,
|
||||||
|
) -> jlong {
|
||||||
|
env.with_env(|env| -> jni::errors::Result<jlong> {
|
||||||
|
let flag = env.get_direct_buffer_address(&cancelled)?.cast::<bool>();
|
||||||
|
let mut decoded = NativeCoreImageInfo::default();
|
||||||
|
// SAFETY: Kotlin passes a live native buffer of `length` bytes and a direct buffer it only
|
||||||
|
// changes through `cancel`, and the output is valid.
|
||||||
|
let rgba = unsafe {
|
||||||
|
native_core_jxl_decode(
|
||||||
|
data as *const u8,
|
||||||
|
length as size_t,
|
||||||
|
false,
|
||||||
|
target_width as u32,
|
||||||
|
target_height as u32,
|
||||||
|
max_pixel_size as u32,
|
||||||
|
flag,
|
||||||
|
&mut decoded,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !rgba.is_null()
|
||||||
|
&& let Err(err) =
|
||||||
|
info.set_region(env, 0, &[decoded.width, decoded.height, decoded.width * 4])
|
||||||
|
{
|
||||||
|
// SAFETY: this malloc buffer has not been transferred to Kotlin.
|
||||||
|
unsafe { libc::free(rgba.cast()) };
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Ok(rgba as jlong)
|
||||||
|
})
|
||||||
|
.resolve::<ThrowRuntimeExAndDefault>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeImage_cancel<'caller>(
|
||||||
|
mut env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
flag: JByteBuffer<'caller>,
|
||||||
|
) {
|
||||||
|
env.with_env(|env| -> jni::errors::Result<()> {
|
||||||
|
let flag = env.get_direct_buffer_address(&flag)?.cast::<bool>();
|
||||||
|
// SAFETY: the buffer is the live one-byte flag Kotlin passed to `decodeJxl`.
|
||||||
|
unsafe { native_core_cancel(flag) };
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.resolve::<ThrowRuntimeExAndDefault>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use std::ffi::{CString, c_char};
|
||||||
|
use std::panic::{self, UnwindSafe};
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android;
|
||||||
|
/// cbindgen:ignore
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod bitmap;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod convert;
|
||||||
|
mod jxl;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod rotate;
|
||||||
|
mod thumbhash;
|
||||||
|
|
||||||
|
/// Returns the core version as a C string. Free it with `native_core_free_string`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn native_core_version() -> *mut c_char {
|
||||||
|
guard(ptr::null_mut(), || {
|
||||||
|
match CString::new(native_core::version()) {
|
||||||
|
Ok(s) => s.into_raw(),
|
||||||
|
Err(_) => ptr::null_mut(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases a string returned by this library. Null is a no-op.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must come from this library and must not be freed twice.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn native_core_free_string(ptr: *mut c_char) {
|
||||||
|
if ptr.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
guard((), || {
|
||||||
|
// SAFETY: the caller guarantees `ptr` came from `CString::into_raw` in this library.
|
||||||
|
drop(unsafe { CString::from_raw(ptr) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guard<T>(fallback: T, f: impl FnOnce() -> T + UnwindSafe) -> T {
|
||||||
|
panic::catch_unwind(f).unwrap_or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::ffi::CStr;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_roundtrips() {
|
||||||
|
let p = native_core_version();
|
||||||
|
assert!(!p.is_null());
|
||||||
|
// SAFETY: `p` is a C string from this library.
|
||||||
|
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
|
||||||
|
assert_eq!(s, native_core::version());
|
||||||
|
// SAFETY: `p` is freed exactly once.
|
||||||
|
unsafe { native_core_free_string(p) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn free_null_is_noop() {
|
||||||
|
// SAFETY: null is allowed by the contract.
|
||||||
|
unsafe { native_core_free_string(ptr::null_mut()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use jni::EnvUnowned;
|
||||||
|
use jni::objects::{JClass, JIntArray, JObject};
|
||||||
|
use jni::sys::{jint, jlong};
|
||||||
|
use native_core::rotate::{Orientation, rotate};
|
||||||
|
|
||||||
|
use super::bitmap::{self, FORMAT_RGBA_8888};
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_NativeImage_rotate<'caller>(
|
||||||
|
env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
bitmap: JObject<'caller>,
|
||||||
|
orientation: jint,
|
||||||
|
out_info: JIntArray<'caller>,
|
||||||
|
) -> jlong {
|
||||||
|
let orientation: Orientation = orientation.into();
|
||||||
|
bitmap::transform(
|
||||||
|
env,
|
||||||
|
bitmap,
|
||||||
|
out_info,
|
||||||
|
FORMAT_RGBA_8888,
|
||||||
|
|w, h| orientation.dimensions(w, h),
|
||||||
|
|src, stride, w, h, dst| rotate(src, stride, w, h, orientation, dst),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use std::{ptr, slice};
|
||||||
|
|
||||||
|
use libc::size_t;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use jni::{
|
||||||
|
EnvUnowned,
|
||||||
|
errors::ThrowRuntimeExAndDefault,
|
||||||
|
objects::{JByteArray, JClass, JIntArray},
|
||||||
|
sys::jlong,
|
||||||
|
};
|
||||||
|
use native_core::thumbhash;
|
||||||
|
|
||||||
|
use super::guard;
|
||||||
|
|
||||||
|
/// Decodes a thumbhash to RGBA. The caller frees the returned buffer with free().
|
||||||
|
/// Returns null on failure.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `hash` must be null or readable for `len` bytes, at most `isize::MAX`.
|
||||||
|
/// `width` and `height` must be null or valid writable pointers, outside `hash`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn native_core_thumbhash(
|
||||||
|
hash: *const u8,
|
||||||
|
len: size_t,
|
||||||
|
width: *mut i32,
|
||||||
|
height: *mut i32,
|
||||||
|
) -> *mut u8 {
|
||||||
|
guard(ptr::null_mut(), || {
|
||||||
|
if hash.is_null() || width.is_null() || height.is_null() {
|
||||||
|
return ptr::null_mut();
|
||||||
|
}
|
||||||
|
// SAFETY: the caller provides a buffer readable for len bytes.
|
||||||
|
let hash = unsafe { slice::from_raw_parts(hash, len) };
|
||||||
|
// SAFETY: calloc accepts any size.
|
||||||
|
let rgba = unsafe { libc::calloc(1, 32 * 32 * 4).cast::<[u8; 32 * 32 * 4]>() };
|
||||||
|
if rgba.is_null() {
|
||||||
|
return ptr::null_mut();
|
||||||
|
}
|
||||||
|
// SAFETY: calloc returned that many zeroed bytes that nothing else references.
|
||||||
|
let Some((w, h)) = thumbhash::decode(hash, unsafe { &mut *rgba }) else {
|
||||||
|
// SAFETY: the buffer has not been returned to the caller.
|
||||||
|
unsafe { libc::free(rgba.cast()) };
|
||||||
|
return ptr::null_mut();
|
||||||
|
};
|
||||||
|
// SAFETY: the outputs are writable.
|
||||||
|
unsafe {
|
||||||
|
*width = w as i32;
|
||||||
|
*height = h as i32;
|
||||||
|
}
|
||||||
|
rgba.cast()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_app_alextran_immich_images_ThumbHash_decode<'caller>(
|
||||||
|
mut env: EnvUnowned<'caller>,
|
||||||
|
_class: JClass<'caller>,
|
||||||
|
hash: JByteArray<'caller>,
|
||||||
|
info: JIntArray<'caller>,
|
||||||
|
) -> jlong {
|
||||||
|
env.with_env(|env| -> jni::errors::Result<jlong> {
|
||||||
|
let hash = env.convert_byte_array(&hash)?;
|
||||||
|
let (mut width, mut height) = (0, 0);
|
||||||
|
// SAFETY: the hash bytes and both output pointers are valid for this call.
|
||||||
|
let rgba =
|
||||||
|
unsafe { native_core_thumbhash(hash.as_ptr(), hash.len(), &mut width, &mut height) };
|
||||||
|
if !rgba.is_null()
|
||||||
|
&& let Err(err) = info.set_region(env, 0, &[width, height, width * 4])
|
||||||
|
{
|
||||||
|
// SAFETY: this malloc buffer has not been transferred to Dart.
|
||||||
|
unsafe { libc::free(rgba.cast()) };
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Ok(rgba as jlong)
|
||||||
|
})
|
||||||
|
.resolve::<ThrowRuntimeExAndDefault>()
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||||
|
|
||||||
|
[[tools.rust]]
|
||||||
|
version = "1.99.1"
|
||||||
|
backend = "core:rust"
|
||||||
|
|
||||||
|
[tools.rust.options]
|
||||||
|
components = "clippy,rustfmt"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[tools]
|
||||||
|
rust = { version = "1.99.1", components = "rustfmt,clippy" } # keep in sync with crates/native_core_ffi/rust-toolchain.toml
|
||||||
|
|
||||||
|
[tasks.build]
|
||||||
|
description = "Build the native crates for the host"
|
||||||
|
run = "cargo build --workspace"
|
||||||
|
|
||||||
|
[tasks.test]
|
||||||
|
description = "Run the Rust tests"
|
||||||
|
run = "cargo test --workspace --locked"
|
||||||
|
|
||||||
|
[tasks.fmt]
|
||||||
|
description = "Check Rust formatting"
|
||||||
|
run = "cargo fmt --all --check"
|
||||||
|
|
||||||
|
[tasks.lint]
|
||||||
|
description = "Run clippy with warnings as errors"
|
||||||
|
run = "cargo clippy --workspace --all-targets --locked -- -D warnings"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.dart_tool/
|
||||||
|
build/
|
||||||
|
pubspec.lock
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
array-init
|
||||||
|
byteorder
|
||||||
|
bytes
|
||||||
|
cfg-if
|
||||||
|
combine
|
||||||
|
crossbeam-deque
|
||||||
|
crossbeam-epoch
|
||||||
|
crossbeam-utils
|
||||||
|
either
|
||||||
|
equivalent
|
||||||
|
hashbrown
|
||||||
|
jni
|
||||||
|
jni-sys
|
||||||
|
libc
|
||||||
|
log
|
||||||
|
memchr
|
||||||
|
num-traits
|
||||||
|
quick_cache
|
||||||
|
rayon
|
||||||
|
rayon-core
|
||||||
|
simd_cesu8
|
||||||
|
simdutf8
|
||||||
|
thiserror
|
||||||
|
|
||||||
|
Copyright (c) 2018 Carl Lerche
|
||||||
|
Copyright (c) 2014 Alex Crichton
|
||||||
|
Copyright (c) 2015 Markus Westerlind
|
||||||
|
Copyright (c) 2016 Prevoty, Inc. and jni-rs contributors
|
||||||
|
Copyright (c) 2015 The rust-jni-sys Developers
|
||||||
|
Copyright (c) The Rust Project Developers
|
||||||
|
Copyright (c) 2014 The Rust Project Developers
|
||||||
|
Copyright (c) 2015 Andrew Gallant
|
||||||
|
Copyright (c) 2017-2020 The array-init developers
|
||||||
|
Copyright (c) 2019 The Crossbeam Project Developers
|
||||||
|
Copyright (c) 2015
|
||||||
|
Copyright (c) 2010 The Rust Project Developers
|
||||||
|
Copyright (c) 2016--2023
|
||||||
|
Copyright (c) 2016 Amanieu d'Antras
|
||||||
|
Copyright (c) 2022 Arthur Silva
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
jxl
|
||||||
|
jxl_simd
|
||||||
|
jxl_transforms
|
||||||
|
moxcms
|
||||||
|
pic-scale
|
||||||
|
pxfm
|
||||||
|
|
||||||
|
Copyright (c) the JPEG XL Project Authors.
|
||||||
|
Copyright (c) Radzivon Bartoshyk.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
formatter:
|
||||||
|
page_width: 120
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
- web/**
|
||||||
|
- windows/**
|
||||||
|
- macos/**
|
||||||
|
- linux/**
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# The asset id must match assetName in hook/build.dart.
|
||||||
|
ffi-native:
|
||||||
|
asset-id: 'package:native_core/src/bindings.g.dart'
|
||||||
|
output: 'lib/src/bindings.g.dart'
|
||||||
|
headers:
|
||||||
|
entry-points:
|
||||||
|
- '../crates/native_core_ffi/include/native_core.h'
|
||||||
|
include-directives:
|
||||||
|
- '**/native_core.h'
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:code_assets/code_assets.dart';
|
||||||
|
import 'package:hooks/hooks.dart';
|
||||||
|
import 'package:native_toolchain_rust/native_toolchain_rust.dart';
|
||||||
|
|
||||||
|
const _crate = '../crates/native_core_ffi';
|
||||||
|
|
||||||
|
// Cargo's dep-info only lists source files, so these have to rerun the hook too.
|
||||||
|
const _manifests = [
|
||||||
|
'../.cargo/config.toml',
|
||||||
|
'../Cargo.toml',
|
||||||
|
'../Cargo.lock',
|
||||||
|
'../crates/native_core/Cargo.toml',
|
||||||
|
'$_crate/Cargo.toml',
|
||||||
|
'$_crate/rust-toolchain.toml',
|
||||||
|
];
|
||||||
|
|
||||||
|
void main(List<String> args) async {
|
||||||
|
await build(args, (input, output) async {
|
||||||
|
if (!input.config.buildCodeAssets) return;
|
||||||
|
final code = input.config.code;
|
||||||
|
output.dependencies.addAll(_manifests.map(input.packageRoot.resolve));
|
||||||
|
final target = _crossTarget(code);
|
||||||
|
if (target != null) {
|
||||||
|
await _rustup(['target', 'add', '--toolchain', await _channel(input), target]);
|
||||||
|
}
|
||||||
|
await RustBuilder(assetName: 'src/bindings.g.dart', cratePath: _crate).run(input: input, output: output);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// mise sets RUSTUP_TOOLCHAIN, which makes rustup ignore rust-toolchain.toml and never install
|
||||||
|
// its targets, so add the one this build needs.
|
||||||
|
String? _crossTarget(CodeConfig code) => switch ((code.targetOS, code.targetArchitecture)) {
|
||||||
|
(OS.android, Architecture.arm) => 'armv7-linux-androideabi',
|
||||||
|
(OS.android, Architecture.arm64) => 'aarch64-linux-android',
|
||||||
|
(OS.android, Architecture.x64) => 'x86_64-linux-android',
|
||||||
|
(OS.iOS, Architecture.arm64) when code.iOS.targetSdk == IOSSdk.iPhoneSimulator => 'aarch64-apple-ios-sim',
|
||||||
|
(OS.iOS, Architecture.arm64) => 'aarch64-apple-ios',
|
||||||
|
(OS.iOS, Architecture.x64) => 'x86_64-apple-ios',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<String> _channel(BuildInput input) async {
|
||||||
|
final toml = await File.fromUri(input.packageRoot.resolve('$_crate/rust-toolchain.toml')).readAsString();
|
||||||
|
final match = RegExp(r'^channel = "([^"]+)"', multiLine: true).firstMatch(toml);
|
||||||
|
if (match == null) throw StateError('channel missing in rust-toolchain.toml');
|
||||||
|
return match.group(1)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _rustup(List<String> args) async {
|
||||||
|
final home = Platform.environment[Platform.isWindows ? 'USERPROFILE' : 'HOME'];
|
||||||
|
ProcessResult result;
|
||||||
|
try {
|
||||||
|
result = await Process.run('rustup', args);
|
||||||
|
} on ProcessException {
|
||||||
|
if (home == null) rethrow;
|
||||||
|
result = await Process.run('$home/.cargo/bin/rustup', args);
|
||||||
|
}
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw ProcessException('rustup', args, '${result.stderr}', result.exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:ffi/ffi.dart';
|
||||||
|
|
||||||
|
import 'src/bindings.g.dart';
|
||||||
|
|
||||||
|
String version() {
|
||||||
|
final ptr = native_core_version();
|
||||||
|
try {
|
||||||
|
return ptr.cast<Utf8>().toDartString();
|
||||||
|
} finally {
|
||||||
|
native_core_free_string(ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: native_core
|
||||||
|
publish_to: none
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
code_assets: ^1.2.1
|
||||||
|
ffi: ^2.2.0
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
hooks: ^2.2.0
|
||||||
|
native_toolchain_rust: ^1.0.6
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
ffigen: ^21.0.0
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:native_core/native_core.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('loads the core and returns its version', () {
|
||||||
|
expect(version(), isNotEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+5
-5
@@ -1177,8 +1177,8 @@ importers:
|
|||||||
specifier: ^5.5.6
|
specifier: ^5.5.6
|
||||||
version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.9)
|
version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.9)
|
||||||
eslint-plugin-svelte:
|
eslint-plugin-svelte:
|
||||||
specifier: ^3.12.4
|
specifier: ^3.23.1
|
||||||
version: 3.23.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1))
|
version: 3.23.1(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1))
|
||||||
eslint-plugin-unicorn:
|
eslint-plugin-unicorn:
|
||||||
specifier: ^75.0.0
|
specifier: ^75.0.0
|
||||||
version: 75.0.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))
|
version: 75.0.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))
|
||||||
@@ -7821,8 +7821,8 @@ packages:
|
|||||||
eslint-config-prettier:
|
eslint-config-prettier:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
eslint-plugin-svelte@3.23.0:
|
eslint-plugin-svelte@3.23.1:
|
||||||
resolution: {integrity: sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==}
|
resolution: {integrity: sha512-+w86Jlx4IRUuuvtqD6j4njql+akx+81JnpvbN5de3AV5iq2SexvST/Jr51CiTlKeY0i+vhoKjPO/ywFKIP75sA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.1 || ^9.0.0 || ^10.0.0
|
eslint: ^8.57.1 || ^9.0.0 || ^10.0.0
|
||||||
@@ -21185,7 +21185,7 @@ snapshots:
|
|||||||
'@types/eslint': 9.6.1
|
'@types/eslint': 9.6.1
|
||||||
eslint-config-prettier: 10.1.8(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))
|
eslint-config-prettier: 10.1.8(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))
|
||||||
|
|
||||||
eslint-plugin-svelte@3.23.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1)):
|
eslint-plugin-svelte@3.23.1(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))
|
'@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))
|
||||||
'@jridgewell/sourcemap-codec': 1.6.0
|
'@jridgewell/sourcemap-codec': 1.6.0
|
||||||
|
|||||||
@@ -818,6 +818,7 @@ export class AssetRepository {
|
|||||||
})
|
})
|
||||||
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
|
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
|
||||||
const order = options.order ?? 'desc';
|
const order = options.order ?? 'desc';
|
||||||
|
const bucket = timeBucket.replace(/^[+-]/, '');
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.with('cte', (qb) =>
|
.with('cte', (qb) =>
|
||||||
qb
|
qb
|
||||||
@@ -869,7 +870,14 @@ export class AssetRepository {
|
|||||||
|
|
||||||
return withBoundingBox(withBoundingCircle, bbox);
|
return withBoundingBox(withBoundingCircle, bbox);
|
||||||
})
|
})
|
||||||
.where(truncatedDate(options.orderBy), '=', timeBucket.replace(/^[+-]/, ''))
|
.$if(options.orderBy === AssetOrderBy.CreatedAt, (qb) =>
|
||||||
|
qb
|
||||||
|
.where('asset.createdAt', '>=', sql<Date>`${bucket}::timestamp AT TIME ZONE 'UTC'`)
|
||||||
|
.where('asset.createdAt', '<', sql<Date>`(${bucket}::timestamp + interval '1 month') AT TIME ZONE 'UTC'`),
|
||||||
|
)
|
||||||
|
.$if(options.orderBy !== AssetOrderBy.CreatedAt, (qb) =>
|
||||||
|
qb.where(truncatedDate(options.orderBy), '=', bucket),
|
||||||
|
)
|
||||||
.$if(!!options.albumId, (qb) =>
|
.$if(!!options.albumId, (qb) =>
|
||||||
qb.where((eb) =>
|
qb.where((eb) =>
|
||||||
eb.exists(
|
eb.exists(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { AssetVisibility, SharedLinkType } from 'src/enum.js';
|
import { AssetOrderBy, AssetVisibility, SharedLinkType } from 'src/enum.js';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository.js';
|
import { AccessRepository } from 'src/repositories/access.repository.js';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository.js';
|
import { AssetRepository } from 'src/repositories/asset.repository.js';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
||||||
@@ -162,6 +162,34 @@ describe(TimelineService.name, () => {
|
|||||||
const rawResponse = await sut.getTimeBucket(factory.auth(), { timeBucket: '012345-01-01' });
|
const rawResponse = await sut.getTimeBucket(factory.auth(), { timeBucket: '012345-01-01' });
|
||||||
const response = JSON.parse(rawResponse);
|
const response = JSON.parse(rawResponse);
|
||||||
expect(response).toEqual(expect.objectContaining({ id: [] }));
|
expect(response).toEqual(expect.objectContaining({ id: [] }));
|
||||||
|
|
||||||
|
const createdAtResponse = await sut.getTimeBucket(factory.auth(), {
|
||||||
|
timeBucket: '012345-01-01',
|
||||||
|
orderBy: AssetOrderBy.CreatedAt,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(createdAtResponse)).toEqual(expect.objectContaining({ id: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bucket by upload month when ordering by createdAt', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user: { id: user.id } });
|
||||||
|
const dates = [
|
||||||
|
'1970-01-31T23:59:59.999Z',
|
||||||
|
'1970-02-01T00:00:00.000Z',
|
||||||
|
'1970-02-28T23:59:59.999Z',
|
||||||
|
'1970-03-01T00:00:00.000Z',
|
||||||
|
];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const createdAt of dates) {
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id, createdAt, localDateTime: new Date('2000-01-01') });
|
||||||
|
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
|
||||||
|
ids.push(asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawResponse = await sut.getTimeBucket(auth, { timeBucket: '1970-02-01', orderBy: AssetOrderBy.CreatedAt });
|
||||||
|
const response = JSON.parse(rawResponse);
|
||||||
|
expect(response.id).toEqual([ids[2], ids[1]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return time bucket in trash', async () => {
|
it('should return time bucket in trash', async () => {
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export default defineConfig(
|
|||||||
'@typescript-eslint/switch-exhaustiveness-check': ['error', { considerDefaultExhaustiveForUnions: true }],
|
'@typescript-eslint/switch-exhaustiveness-check': ['error', { considerDefaultExhaustiveForUnions: true }],
|
||||||
'svelte/button-has-type': 'error',
|
'svelte/button-has-type': 'error',
|
||||||
'object-shorthand': ['error', 'always'],
|
'object-shorthand': ['error', 'always'],
|
||||||
|
'svelte/no-at-const-tags': 'error',
|
||||||
'svelte/no-navigation-without-resolve': 'off',
|
'svelte/no-navigation-without-resolve': 'off',
|
||||||
'unicorn/prefer-early-return': 'off',
|
'unicorn/prefer-early-return': 'off',
|
||||||
'unicorn/prefer-ternary': 'off',
|
'unicorn/prefer-ternary': 'off',
|
||||||
|
|||||||
+2
-2
@@ -36,8 +36,8 @@
|
|||||||
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
|
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
|
||||||
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
|
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
|
||||||
"@photo-sphere-viewer/video-plugin": "^5.14.0",
|
"@photo-sphere-viewer/video-plugin": "^5.14.0",
|
||||||
"@tanstack/svelte-query": "^6.3.2",
|
|
||||||
"@simplewebauthn/browser": "14.0.0",
|
"@simplewebauthn/browser": "14.0.0",
|
||||||
|
"@tanstack/svelte-query": "^6.3.2",
|
||||||
"@types/geojson": "^7946.0.16",
|
"@types/geojson": "^7946.0.16",
|
||||||
"@zoom-image/core": "^0.43.0",
|
"@zoom-image/core": "^0.43.0",
|
||||||
"@zoom-image/svelte": "^0.3.0",
|
"@zoom-image/svelte": "^0.3.0",
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"eslint-plugin-better-tailwindcss": "^4.5.0",
|
"eslint-plugin-better-tailwindcss": "^4.5.0",
|
||||||
"eslint-plugin-compat": "^7.0.0",
|
"eslint-plugin-compat": "^7.0.0",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"eslint-plugin-svelte": "^3.12.4",
|
"eslint-plugin-svelte": "^3.23.1",
|
||||||
"eslint-plugin-unicorn": "^75.0.0",
|
"eslint-plugin-unicorn": "^75.0.0",
|
||||||
"factory.ts": "^1.4.1",
|
"factory.ts": "^1.4.1",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
|
|||||||
@@ -70,9 +70,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each data.series as day, idx (day.date)}
|
{#each data.series as day, idx (day.date)}
|
||||||
{@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
|
{const date = $derived(
|
||||||
{ month: 'short', day: 'numeric' },
|
DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
|
||||||
{ locale: $locale },
|
{ month: 'short', day: 'numeric' },
|
||||||
|
{ locale: $locale },
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
class="aspect-square size-full rounded-sm {itemColors(day.count)} row-start-(--heatmap-row-start)"
|
class="aspect-square size-full rounded-sm {itemColors(day.count)} row-start-(--heatmap-row-start)"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
{@render button()}
|
{@render button()}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@const albumId = albumIds[0]}
|
{const albumId = $derived(albumIds[0])}
|
||||||
{#if albumId}
|
{#if albumId}
|
||||||
<AlbumThumbnail {albumId} onDelete={() => (albumIds = [])} />
|
<AlbumThumbnail {albumId} onDelete={() => (albumIds = [])} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<Select options={schema.enum} bind:value={getString, setValue} />
|
<Select options={schema.enum} bind:value={getString, setValue} />
|
||||||
</Field>
|
</Field>
|
||||||
{:else if schema.array}
|
{:else if schema.array}
|
||||||
{@const values = getValue<string[]>([])}
|
{const values = $derived(getValue<string[]>([]))}
|
||||||
<Field {label} {description}>
|
<Field {label} {description}>
|
||||||
<Input value={values.join(',')} disabled />
|
<Input value={values.join(',')} disabled />
|
||||||
<HelperText>{$t('unsupported_field_type')}</HelperText>
|
<HelperText>{$t('unsupported_field_type')}</HelperText>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
preload = false,
|
preload = false,
|
||||||
contextMenuItems,
|
contextMenuItems,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const owner = $derived(album.albumUsers[0].user);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -74,7 +76,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showOwner}
|
{#if showOwner}
|
||||||
{@const owner = album.albumUsers[0].user}
|
|
||||||
{#if owner.id === authManager.user.id}
|
{#if owner.id === authManager.user.id}
|
||||||
<p>{$t('owned')}</p>
|
<p>{$t('owned')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -66,8 +66,8 @@
|
|||||||
{#if !isCollapsed}
|
{#if !isCollapsed}
|
||||||
<div class="grid grid-auto-fill-56 gap-y-4" transition:slide={{ duration: 300 }}>
|
<div class="grid grid-auto-fill-56 gap-y-4" transition:slide={{ duration: 300 }}>
|
||||||
{#each albums as album, index (album.id)}
|
{#each albums as album, index (album.id)}
|
||||||
{@const { Edit, Share, Download, Leave, Delete } = getAlbumActions($t, album)}
|
{const { Edit, Share, Download, Leave, Delete } = $derived(getAlbumActions($t, album))}
|
||||||
{@const items = [Edit, Share, Download, Leave, Delete]}
|
{const items = $derived([Edit, Share, Download, Leave, Delete])}
|
||||||
<a
|
<a
|
||||||
href={Route.viewAlbum(album)}
|
href={Route.viewAlbum(album)}
|
||||||
class="h-fit"
|
class="h-fit"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
|
||||||
import MapModal from '$lib/modals/MapModal.svelte';
|
import MapModal from '$lib/modals/MapModal.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { getAlbumMapMarkersQuery } from '$lib/queries/album.query';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { getAlbumMapMarkers, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
|
import { type AlbumResponseDto } from '@immich/sdk';
|
||||||
import { IconButton, modalManager } from '@immich/ui';
|
import { IconButton, modalManager } from '@immich/ui';
|
||||||
import { mdiMapOutline } from '@mdi/js';
|
import { mdiMapOutline } from '@mdi/js';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,17 +15,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { album }: Props = $props();
|
let { album }: Props = $props();
|
||||||
let cancelable: AbortController;
|
|
||||||
|
|
||||||
let returnToMap = $state(false);
|
let returnToMap = $state(false);
|
||||||
let mapMarkers: MapMarkerResponseDto[] = $state([]);
|
const query = createQuery(() => getAlbumMapMarkersQuery(album.id));
|
||||||
|
const mapMarkers = $derived(query.data ?? []);
|
||||||
onMount(async () => {
|
|
||||||
mapMarkers = await loadMapMarkers();
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
cancelable?.abort();
|
|
||||||
assetViewerManager.showAssetViewer(false);
|
assetViewerManager.showAssetViewer(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,18 +33,6 @@
|
|||||||
void onClick();
|
void onClick();
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadMapMarkers = async () => {
|
|
||||||
cancelable?.abort();
|
|
||||||
cancelable = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await getAlbumMapMarkers({ ...authManager.params, id: album.id }, { signal: cancelable.signal });
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.something_went_wrong'));
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClick = async () => {
|
const onClick = async () => {
|
||||||
const assetIds = await modalManager.show(MapModal, { mapMarkers });
|
const assetIds = await modalManager.show(MapModal, { mapMarkers });
|
||||||
if (assetIds) {
|
if (assetIds) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
|
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { getAlbumQuery } from '$lib/queries/album.query';
|
||||||
import { getAlbumInfo } from '@immich/sdk';
|
import { queryClient } from '$lib/queries/client';
|
||||||
import { IconButton, Text, LoadingSpinner } from '@immich/ui';
|
import { IconButton, Text, LoadingSpinner } from '@immich/ui';
|
||||||
import { mdiTrashCanOutline } from '@mdi/js';
|
import { mdiTrashCanOutline } from '@mdi/js';
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,12 +13,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { albumId, onDelete }: Props = $props();
|
let { albumId, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
const query = createQuery(
|
||||||
|
() => getAlbumQuery(albumId),
|
||||||
|
() => queryClient,
|
||||||
|
);
|
||||||
|
const album = $derived(query.data);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#await getAlbumInfo({ ...authManager.params, id: albumId })}
|
{#if query.isPending}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:then album}
|
{:else if album}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<AlbumCover {album} class="size-24" />
|
<AlbumCover {album} class="size-24" />
|
||||||
<p
|
<p
|
||||||
@@ -46,7 +53,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:catch}
|
{:else}
|
||||||
<div class="flex justify-between gap-2">
|
<div class="flex justify-between gap-2">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<Text>{$t('unknown')}</Text>
|
<Text>{$t('unknown')}</Text>
|
||||||
@@ -63,5 +70,5 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AlbumCardGroup from '$lib/components/album-page/AlbumCardGroup.svelte';
|
import AlbumCardGroup from '$lib/components/album-page/AlbumCardGroup.svelte';
|
||||||
import AlbumsTable from '$lib/components/album-page/AlbumsTable.svelte';
|
import AlbumsTable from '$lib/components/album-page/AlbumsTable.svelte';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { handleDeleteAlbum, isAlbumOwner } from '$lib/services/album.service';
|
import { handleDeleteAlbum, isAlbumOwner } from '$lib/services/album.service';
|
||||||
import {
|
import {
|
||||||
@@ -32,8 +31,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ownedAlbums = $bindable([]),
|
ownedAlbums = [],
|
||||||
sharedAlbums = $bindable([]),
|
sharedAlbums = [],
|
||||||
searchQuery = '',
|
searchQuery = '',
|
||||||
userSettings,
|
userSettings,
|
||||||
allowEdit = false,
|
allowEdit = false,
|
||||||
@@ -166,29 +165,8 @@
|
|||||||
const albumsToRemove = ownedAlbums.filter((album) => album.assetCount === 0 && !album.albumName);
|
const albumsToRemove = ownedAlbums.filter((album) => album.assetCount === 0 && !album.albumName);
|
||||||
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album, { prompt: false, notify: false })));
|
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album, { prompt: false, notify: false })));
|
||||||
};
|
};
|
||||||
|
|
||||||
const findAndUpdate = (albums: AlbumResponseDto[], album: AlbumResponseDto) => {
|
|
||||||
const target = albums.find(({ id }) => id === album.id);
|
|
||||||
if (target) {
|
|
||||||
Object.assign(target, album);
|
|
||||||
}
|
|
||||||
|
|
||||||
return albums;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAlbumUpdate = (album: AlbumResponseDto) => {
|
|
||||||
ownedAlbums = findAndUpdate(ownedAlbums, album);
|
|
||||||
sharedAlbums = findAndUpdate(sharedAlbums, album);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAlbumDelete = (album: AlbumResponseDto) => {
|
|
||||||
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
|
|
||||||
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<OnEvents {onAlbumUpdate} {onAlbumDelete} />
|
|
||||||
|
|
||||||
{#if albums.length > 0}
|
{#if albums.length > 0}
|
||||||
{#if userSettings.view === AlbumViewMode.Cover}
|
{#if userSettings.view === AlbumViewMode.Cover}
|
||||||
<!-- Album Cards -->
|
<!-- Album Cards -->
|
||||||
|
|||||||
@@ -39,8 +39,8 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
{:else}
|
{:else}
|
||||||
{#each groupedAlbums as albumGroup (albumGroup.id)}
|
{#each groupedAlbums as albumGroup (albumGroup.id)}
|
||||||
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
|
{const isCollapsed = $derived(isAlbumGroupCollapsed($albumViewSettings, albumGroup.id))}
|
||||||
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
|
{const iconRotation = $derived(isCollapsed ? 'rotate-0' : 'rotate-90')}
|
||||||
<tbody
|
<tbody
|
||||||
class="mt-4 block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
|
class="mt-4 block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -649,10 +649,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if stack && withStacked && !assetViewerManager.isShowEditor && $slideshowState === SlideshowState.None}
|
{#if stack && withStacked && !assetViewerManager.isShowEditor && $slideshowState === SlideshowState.None}
|
||||||
{@const stackedAssets = stack.assets}
|
|
||||||
<div id="stack-slideshow" class="absolute bottom-0 col-span-4 col-start-1 w-fit max-w-full">
|
<div id="stack-slideshow" class="absolute bottom-0 col-span-4 col-start-1 w-fit max-w-full">
|
||||||
<div class="no-wrap horizontal-scrollbar relative flex flex-row overflow-x-auto overflow-y-hidden">
|
<div class="no-wrap horizontal-scrollbar relative flex flex-row overflow-x-auto overflow-y-hidden">
|
||||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
{#each stack.assets as stackedAsset (stackedAsset.id)}
|
||||||
<div
|
<div
|
||||||
class={['relative inline-block px-1 pb-2 transition-all']}
|
class={['relative inline-block px-1 pb-2 transition-all']}
|
||||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||||
|
|||||||
@@ -142,11 +142,11 @@
|
|||||||
<ActionMenuItem action={StackActions.RemoveAsset} />
|
<ActionMenuItem action={StackActions.RemoveAsset} />
|
||||||
|
|
||||||
{#if album}
|
{#if album}
|
||||||
{@const { SetCover } = getAlbumAssetActions($t, album, asset)}
|
{const { SetCover } = $derived(getAlbumAssetActions($t, album, asset))}
|
||||||
<ActionMenuItem action={SetCover} />
|
<ActionMenuItem action={SetCover} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if person}
|
{#if person}
|
||||||
{@const { SetFeaturedPhoto } = getPersonAssetActions($t, person, asset)}
|
{const { SetFeaturedPhoto } = $derived(getPersonAssetActions($t, person, asset))}
|
||||||
<ActionMenuItem action={SetFeaturedPhoto} />
|
<ActionMenuItem action={SetFeaturedPhoto} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -13,26 +13,20 @@
|
|||||||
import { getAssetMediaUrl } from '$lib/utils';
|
import { getAssetMediaUrl } from '$lib/utils';
|
||||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { getParentPath } from '$lib/utils/tree-utils';
|
import { getParentPath } from '$lib/utils/tree-utils';
|
||||||
import {
|
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||||
AssetMediaSize,
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
getAllAlbums,
|
|
||||||
getAssetInfo,
|
|
||||||
type AlbumResponseDto,
|
|
||||||
type AssetResponseDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
|
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
|
||||||
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
|
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
|
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
|
||||||
import OnEvents from '../OnEvents.svelte';
|
|
||||||
import UserAvatar from '../shared-components/UserAvatar.svelte';
|
import UserAvatar from '../shared-components/UserAvatar.svelte';
|
||||||
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
|
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
|
||||||
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
|
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
|
||||||
import { faceManager } from '$lib/stores/face.svelte';
|
import { faceManager } from '$lib/stores/face.svelte';
|
||||||
|
import { getAlbumsByAssetQuery } from '$lib/queries/album.query';
|
||||||
import { createPreloadedQuery } from '$lib/queries/client';
|
import { createPreloadedQuery } from '$lib/queries/client';
|
||||||
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
|
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
|
||||||
|
|
||||||
@@ -60,20 +54,11 @@
|
|||||||
let previousId: string | undefined = $state();
|
let previousId: string | undefined = $state();
|
||||||
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
|
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
|
||||||
|
|
||||||
const refreshAlbums = async () => {
|
const albumsQuery = createQuery(() => ({
|
||||||
if (authManager.isSharedLink) {
|
...getAlbumsByAssetQuery(asset.id),
|
||||||
return [];
|
enabled: !authManager.isSharedLink,
|
||||||
}
|
}));
|
||||||
|
const albums = $derived(albumsQuery.data ?? []);
|
||||||
try {
|
|
||||||
return await getAllAlbums({ assetId: asset.id });
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, 'Error getting asset album membership');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let albums = $derived(refreshAlbums());
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!previousId) {
|
if (!previousId) {
|
||||||
@@ -116,8 +101,6 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
|
|
||||||
|
|
||||||
{#if !assetViewerManager.isEditFacesPanelOpen}
|
{#if !assetViewerManager.isEditFacesPanelOpen}
|
||||||
<section class="relative p-2">
|
<section class="relative p-2">
|
||||||
<div class="flex place-items-center gap-2">
|
<div class="flex place-items-center gap-2">
|
||||||
@@ -203,7 +186,7 @@
|
|||||||
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
|
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{@const { width, height } = getDimensions(asset.exifInfo)}
|
{const { width, height } = $derived(getDimensions(asset.exifInfo))}
|
||||||
<p>{width} × {height}</p>
|
<p>{width} × {height}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if asset.exifInfo?.fileSizeInByte}
|
{#if asset.exifInfo?.fileSizeInByte}
|
||||||
@@ -312,8 +295,8 @@
|
|||||||
onOpenInMapView={() => goto(Route.map({ ...latlng, zoom: 12.5 }))}
|
onOpenInMapView={() => goto(Route.map({ ...latlng, zoom: 12.5 }))}
|
||||||
>
|
>
|
||||||
{#snippet popup({ marker })}
|
{#snippet popup({ marker })}
|
||||||
{@const { lat, lon } = marker}
|
|
||||||
<div class="flex flex-col items-center gap-1">
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
{const { lat, lon } = $derived(marker)}
|
||||||
<Text fontWeight="bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</Text>
|
<Text fontWeight="bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</Text>
|
||||||
<Link
|
<Link
|
||||||
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
|
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
|
||||||
@@ -345,39 +328,37 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#await albums then albums}
|
{#if albums.length > 0}
|
||||||
{#if albums.length > 0}
|
<section class="p-6 dark:text-immich-dark-fg">
|
||||||
<section class="p-6 dark:text-immich-dark-fg">
|
<div class="pb-4">
|
||||||
<div class="pb-4">
|
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
||||||
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
</div>
|
||||||
</div>
|
{#each albums as album (album.id)}
|
||||||
{#each albums as album (album.id)}
|
<a href={Route.viewAlbum(album)}>
|
||||||
<a href={Route.viewAlbum(album)}>
|
<div class="flex items-center gap-4 pt-2 hover:cursor-pointer">
|
||||||
<div class="flex items-center gap-4 pt-2 hover:cursor-pointer">
|
<div>
|
||||||
<div>
|
<img
|
||||||
<img
|
alt={album.albumName}
|
||||||
alt={album.albumName}
|
class="size-12.5 rounded-sm object-cover"
|
||||||
class="size-12.5 rounded-sm object-cover"
|
src={album.albumThumbnailAssetId &&
|
||||||
src={album.albumThumbnailAssetId &&
|
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
|
||||||
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
|
draggable="false"
|
||||||
draggable="false"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-auto">
|
<div class="my-auto">
|
||||||
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
|
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
|
||||||
<div class="flex flex-col gap-0 text-sm">
|
<div class="flex flex-col gap-0 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<AlbumListItemDetails {album} />
|
<AlbumListItemDetails {album} />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
{/each}
|
</a>
|
||||||
</section>
|
{/each}
|
||||||
{/if}
|
</section>
|
||||||
{/await}
|
{/if}
|
||||||
|
|
||||||
{#if authManager.authenticated && authManager.preferences.tags.enabled}
|
{#if authManager.authenticated && authManager.preferences.tags.enabled}
|
||||||
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||||
|
|||||||
@@ -103,8 +103,10 @@
|
|||||||
|
|
||||||
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
|
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
|
||||||
{#each visiblePeople as person (person.id)}
|
{#each visiblePeople as person (person.id)}
|
||||||
{@const personFaces = faceManager.facesByPersonId.get(person.id) ?? []}
|
{const personFaces = $derived(faceManager.facesByPersonId.get(person.id) ?? [])}
|
||||||
{@const isHighlighted = personFaces.some((f) => assetViewerManager.highlightedFaces.some((b) => b.id === f.id))}
|
{const isHighlighted = $derived(
|
||||||
|
personFaces.some((f) => assetViewerManager.highlightedFaces.some((b) => b.id === f.id)),
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
class="group outline-none"
|
class="group outline-none"
|
||||||
href={Route.viewPerson(person, { previousRoute })}
|
href={Route.viewPerson(person, { previousRoute })}
|
||||||
|
|||||||
@@ -265,7 +265,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{#each boundingBoxes as boundingbox (boundingbox.id)}
|
{#each boundingBoxes as boundingbox (boundingbox.id)}
|
||||||
{@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)}
|
{const isActive = $derived(assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id))}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto absolute rounded-lg {isActive && 'border-3 border-solid border-white'}"
|
class="pointer-events-auto absolute rounded-lg {isActive && 'border-3 border-solid border-white'}"
|
||||||
@@ -274,9 +274,10 @@
|
|||||||
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
|
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
|
||||||
>
|
>
|
||||||
{#if isActive && boundingbox.name}
|
{#if isActive && boundingbox.name}
|
||||||
{@const labelAbove =
|
{const labelAbove = $derived(
|
||||||
boundingbox.top + boundingbox.height + faceLabelOffset > overlaySize.height &&
|
boundingbox.top + boundingbox.height + faceLabelOffset > overlaySize.height &&
|
||||||
boundingbox.top >= faceLabelOffset}
|
boundingbox.top >= faceLabelOffset,
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="absolute rounded-sm bg-white/90 px-2 py-1 text-sm font-medium whitespace-nowrap text-black shadow-lg"
|
class="absolute rounded-sm bg-white/90 px-2 py-1 text-sm font-medium whitespace-nowrap text-black shadow-lg"
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
{#each edges as edge (edge)}
|
{#each edges as edge (edge)}
|
||||||
{@const rotatedEdge = rotateBoundary(edges, edge, transformManager.normalizedRotation / 90)}
|
{const rotatedEdge = $derived(rotateBoundary(edges, edge, transformManager.normalizedRotation / 90))}
|
||||||
<button
|
<button
|
||||||
class={['absolute', edge]}
|
class={['absolute', edge]}
|
||||||
style={`${edge}: -10px`}
|
style={`${edge}: -10px`}
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each corners as corner (corner)}
|
{#each corners as corner (corner)}
|
||||||
{@const rotatedCorner = rotateBoundary(corners, corner, transformManager.normalizedRotation / 90)}
|
{const rotatedCorner = $derived(rotateBoundary(corners, corner, transformManager.normalizedRotation / 90))}
|
||||||
<button
|
<button
|
||||||
class={['corner', corner]}
|
class={['corner', corner]}
|
||||||
onmousedown={(e) => transformManager.handleMouseDownOn(e, corner)}
|
onmousedown={(e) => transformManager.handleMouseDownOn(e, corner)}
|
||||||
|
|||||||
@@ -238,8 +238,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each peopleWithFaces as face, index (face.id)}
|
{#each peopleWithFaces as face, index (face.id)}
|
||||||
{@const personName = face.person ? face.person.name : $t('face_unassigned')}
|
{const personName = face.person ? face.person.name : $t('face_unassigned')}
|
||||||
{@const isHighlighted = assetViewerManager.highlightedFaces.some((b) => b.id === face.id)}
|
{const isHighlighted = $derived(assetViewerManager.highlightedFaces.some((b) => b.id === face.id))}
|
||||||
<div class="relative h-29 w-24">
|
<div class="relative h-29 w-24">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -348,8 +348,8 @@
|
|||||||
>
|
>
|
||||||
{#each assets as asset, index (asset.id + '-' + index)}
|
{#each assets as asset, index (asset.id + '-' + index)}
|
||||||
{#if isInOrNearViewport(index)}
|
{#if isInOrNearViewport(index)}
|
||||||
{@const currentAsset = toTimelineAsset(asset)}
|
|
||||||
<div class="absolute" style:overflow="clip" style={getStyle(index)}>
|
<div class="absolute" style:overflow="clip" style={getStyle(index)}>
|
||||||
|
{const currentAsset = toTimelineAsset(asset)}
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
readonly={disableAssetSelect}
|
readonly={disableAssetSelect}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -100,9 +100,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each filteredSearchTerms as savedSearchTerm, i (i)}
|
{#each filteredSearchTerms as savedSearchTerm, i (i)}
|
||||||
{@const index = showClearAll ? i + 1 : i}
|
|
||||||
<div class="flex w-full items-center justify-between text-black dark:text-gray-300">
|
<div class="flex w-full items-center justify-between text-black dark:text-gray-300">
|
||||||
<div class="relative w-full items-center">
|
<div class="relative w-full items-center">
|
||||||
|
{const index = $derived(showClearAll ? i + 1 : i)}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
id={getId(index)}
|
id={getId(index)}
|
||||||
|
|||||||
@@ -46,14 +46,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{:then people}
|
{:then people}
|
||||||
{#if people && people.length > 0}
|
{#if people && people.length > 0}
|
||||||
{@const peopleList = showAllPeople
|
|
||||||
? filterPeople(people, name)
|
|
||||||
: filterPeople(people, name).slice(0, numberOfPeople)}
|
|
||||||
|
|
||||||
<div id="people-selection" class="max-h-80 immich-scrollbar overflow-y-auto">
|
<div id="people-selection" class="max-h-80 immich-scrollbar overflow-y-auto">
|
||||||
<Text class="pb-5">{$t('people_search_description')}</Text>
|
<Text class="pb-5">{$t('people_search_description')}</Text>
|
||||||
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
|
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
|
||||||
|
|
||||||
|
{const peopleList = $derived(
|
||||||
|
showAllPeople ? filterPeople(people, name) : filterPeople(people, name).slice(0, numberOfPeople),
|
||||||
|
)}
|
||||||
|
|
||||||
<SingleGridRow
|
<SingleGridRow
|
||||||
class="space-between grid immich-scrollbar grid-auto-fill-20 gap-5 overflow-y-auto pt-5"
|
class="space-between grid immich-scrollbar grid-auto-fill-20 gap-5 overflow-y-auto pt-5"
|
||||||
bind:itemCount={numberOfPeople}
|
bind:itemCount={numberOfPeople}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
{#if selectedTags?.size}
|
{#if selectedTags?.size}
|
||||||
<section class="flex flex-wrap gap-2 pt-5">
|
<section class="flex flex-wrap gap-2 pt-5">
|
||||||
{#each selectedTags as tagId (tagId)}
|
{#each selectedTags as tagId (tagId)}
|
||||||
{@const tag = tagMap[tagId]}
|
{const tag = $derived(tagMap[tagId])}
|
||||||
{#if tag}
|
{#if tag}
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user