5 Commits
Author SHA1 Message Date
Codex f94fbfe14e Integrate native core build prerequisites for local Android release 2026-10-24 11:58:44 +03:00
MertandCodex 70ef2884f0 feat(mobile): jxl decoder (#32592) 2026-10-24 11:52:05 +03:00
Santo ShakilandCodex 918d66fbdb fix(mobile): cancel cronet image requests only between reads (#32659)
* fix(mobile): cancel cronet image requests only between reads

* rename cronet callback flags
2026-10-24 11:51:43 +03:00
Jason RasmussenCodexcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>jrasm91
5d1167a36c refactor: apikeys, passkeys, and sessions (#32670)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jrasm91 <4334196+jrasm91@users.noreply.github.com>
2026-10-24 11:51:43 +03:00
Jason RasmussenandCodex e3bf54e2ba refactor: album queries (#32668) 2026-10-24 11:51:43 +03:00
114 changed files with 3789 additions and 1218 deletions
-13
View File
@@ -1,13 +0,0 @@
cmake_minimum_required(VERSION 3.12)
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
src/main/cpp/native_image.c
)
target_link_libraries(native_buffer jnigraphics)
-5
View File
@@ -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
@@ -56,10 +57,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 +97,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_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 +308,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 +368,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 +405,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);
});
}
+39
View File
@@ -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);
} }
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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.9+3030502
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));
+10
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
/target
/crates/native_core_ffi/include/native_core.h
/native_core/lib/src/bindings.g.dart
+659
View File
@@ -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"
+27
View File
@@ -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
+13
View File
@@ -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`.
+16
View File
@@ -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
+92
View File
@@ -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(())
}
+15
View File
@@ -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);
}
}
}
+202
View File
@@ -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);
}
}
+235
View File
@@ -0,0 +1,235 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use jxl::api::{
AlphaMode, Endianness, Event, ExtraChannel, JxlColorEncoding, JxlColorProfile, JxlColorType,
JxlDataFormat, JxlDecoder, JxlDecoderOptions, JxlOutputBuffer, JxlParallelRunner,
JxlParallelRunnerFun, JxlPixelFormat, JxlPrimaries, JxlTransferFunction, JxlWhitePoint,
};
use moxcms::{Layout, MatrixCoefficients, Transform16BitExecutor};
use rayon::prelude::*;
use crate::color::{self, CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
use crate::image::{Image, Pixels, Samples};
use crate::resize;
/// A JPEG XL image whose header has been read.
pub struct Jxl<'a> {
decoder: JxlDecoder,
input: &'a [u8],
/// The space of the output.
cicp: CicpProfile,
/// Converts wide samples to sRGB when the output is not in the source space.
transform: Option<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) = color::to_srgb(&profile.try_as_icc()?)?;
decoder
.set_pixel_format(JxlPixelFormat {
color_type: match layout {
Layout::GrayAlpha => JxlColorType::GrayscaleAlpha,
_ => JxlColorType::Rgba,
},
color_data_format: Some(JxlDataFormat::U16 {
endianness: Endianness::native(),
bit_depth: 12,
}),
extra_channel_format: vec![None; extra],
})
.ok()?;
Some(Self {
decoder,
input,
cicp: color::SRGB,
transform: Some(transform),
})
}
/// Decodes the first frame unless cancelled, downscaled to cover `target` pixels and to fit
/// within `max_pixel_size`.
pub fn decode(
mut self,
target: Option<(usize, usize)>,
max_pixel_size: usize,
cancelled: &AtomicBool,
) -> Option<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,
})
}
+11
View File
@@ -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")
}
+103
View File
@@ -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))
);
}
}
+78
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+200
View File
@@ -0,0 +1,200 @@
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 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));
}
+32
View File
@@ -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}");
}
}
+23
View File
@@ -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
+13
View File
@@ -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,
)
}
+162
View File
@@ -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>();
}
+68
View File
@@ -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>()
}
+8
View File
@@ -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"
+18
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
.dart_tool/
build/
pubspec.lock
+94
View File
@@ -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.
+14
View File
@@ -0,0 +1,14 @@
include: package:flutter_lints/flutter.yaml
formatter:
page_width: 120
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
+9
View File
@@ -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'
+63
View File
@@ -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);
}
}
+12
View File
@@ -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);
}
}
+19
View File
@@ -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);
});
}
@@ -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 -->
@@ -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">
@@ -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">
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/svelte'; import { render, screen } from '@testing-library/svelte';
import { tick } from 'svelte';
import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock';
import RecentAlbums from '$lib/components/shared-components/side-bar/RecentAlbums.svelte'; import RecentAlbums from '$lib/components/shared-components/side-bar/RecentAlbums.svelte';
import { albumFactory } from '@test-data/factories/album-factory'; import { albumFactory } from '@test-data/factories/album-factory';
@@ -16,13 +15,8 @@ describe('RecentAlbums component', () => {
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]); sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
render(RecentAlbums); render(RecentAlbums);
const links = await screen.findAllByRole('link');
expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce(); expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce();
// wtf
await tick();
await tick();
const links = screen.getAllByRole('link');
expect(links).toHaveLength(3); expect(links).toHaveLength(3);
// @ts-expect-error pending a type fix: https://github.com/testing-library/jest-dom/pull/742/changes // @ts-expect-error pending a type fix: https://github.com/testing-library/jest-dom/pull/742/changes
expect(links[0]).toHaveAttribute('href', `/albums/${albums[2].id}`); expect(links[0]).toHaveAttribute('href', `/albums/${albums[2].id}`);
@@ -1,28 +1,12 @@
<script lang="ts"> <script lang="ts">
import { getRecentAlbumsQuery } from '$lib/queries/album.query';
import { queryClient } from '$lib/queries/client';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { userInteraction } from '$lib/stores/user.svelte';
import { getAssetMediaUrl } from '$lib/utils'; import { getAssetMediaUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { createQuery } from '@tanstack/svelte-query';
import { getAllAlbums } from '@immich/sdk';
import { t } from 'svelte-i18n';
let albums = $state(userInteraction.recentAlbums); const query = createQuery(getRecentAlbumsQuery, () => queryClient);
const albums = $derived(query.data ?? []);
const refreshAlbums = async () => {
try {
const allAlbums = await getAllAlbums({});
albums = allAlbums.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()).slice(0, 3);
userInteraction.recentAlbums = albums;
} catch (error) {
handleError(error, $t('failed_to_load_assets'));
}
};
$effect(() => {
if (!userInteraction.recentAlbums) {
void refreshAlbums();
}
});
</script> </script>
{#each albums as album (album.id)} {#each albums as album (album.id)}
+7 -5
View File
@@ -1,6 +1,7 @@
<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 { handleUpdateAlbum } from '$lib/services/album.service'; import { updateAlbumInfoMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { type AlbumResponseDto } from '@immich/sdk'; import { type AlbumResponseDto } from '@immich/sdk';
import { Field, FormModal, Input, Textarea } from '@immich/ui'; import { Field, FormModal, Input, Textarea } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js'; import { mdiRenameOutline } from '@mdi/js';
@@ -17,10 +18,11 @@
let description = $state(album.description); let description = $state(album.description);
const onSubmit = async () => { const onSubmit = async () => {
const success = await handleUpdateAlbum(album, { albumName, description: description || null }); await executeMutation(
if (success) { updateAlbumInfoMutation,
onClose(); [{ id: album.id, updateAlbumDto: { albumName, description: description || null } }],
} { onSuccess: onClose },
);
}; };
</script> </script>
+24 -25
View File
@@ -1,19 +1,20 @@
<script lang="ts"> <script lang="ts">
import AlbumSharedLink from '$lib/components/album-page/AlbumSharedLink.svelte'; import AlbumSharedLink from '$lib/components/album-page/AlbumSharedLink.svelte';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte'; import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte'; import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import { getAlbumActions, handleRemoveUserFromAlbum, handleUpdateUserAlbumRole } from '$lib/services/album.service';
import { getAlbumQuery, updateAlbumInfoMutation } from '$lib/queries/album.query';
import { executeMutation, queryClient } from '$lib/queries/client';
import { getAllSharedLinksQuery } from '$lib/queries/shared-link.query';
import { import {
getAlbumActions, AlbumUserRole,
handleRemoveUserFromAlbum, AssetOrder,
handleUpdateAlbum, type AlbumResponseDto,
handleUpdateUserAlbumRole, type UpdateAlbumDto,
} from '$lib/services/album.service'; type UserResponseDto,
import { AlbumUserRole, AssetOrder, getAlbumInfo, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk'; } from '@immich/sdk';
import { Field, HStack, Modal, ModalBody, Select, Stack, Switch, Text, type SelectOption } from '@immich/ui'; import { Field, HStack, Modal, ModalBody, Select, Stack, Switch, Text, type SelectOption } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { queryClient } from '$lib/queries/client';
import { getAllSharedLinksQuery } from '$lib/queries/shared-link.query';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
@@ -22,7 +23,13 @@
onClose: () => void; onClose: () => void;
}; };
let { album, readOnly = false, onClose }: Props = $props(); let { album: initialAlbum, readOnly = false, onClose }: Props = $props();
const query = createQuery(
() => getAlbumQuery(initialAlbum.id),
() => queryClient,
);
const album = $derived(query.data ?? initialAlbum);
const handleRoleSelect = async (user: UserResponseDto, role: AlbumUserRole | 'none') => { const handleRoleSelect = async (user: UserResponseDto, role: AlbumUserRole | 'none') => {
if (role === 'none') { if (role === 'none') {
@@ -33,26 +40,18 @@
await handleUpdateUserAlbumRole({ albumId: album.id, userId: user.id, role }); await handleUpdateUserAlbumRole({ albumId: album.id, userId: user.id, role });
}; };
const refreshAlbum = async () => {
album = await getAlbumInfo({ id: album.id });
};
const onAlbumUserDelete = async ({ userId }: { userId: string }) => {
album.albumUsers = album.albumUsers.filter(({ user: { id } }) => id !== userId);
await refreshAlbum();
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album)); const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
const query = createQuery( const updateAlbum = (updateAlbumDto: UpdateAlbumDto) =>
executeMutation(updateAlbumInfoMutation, [{ id: album.id, updateAlbumDto }]);
const sharedLinksQuery = createQuery(
() => getAllSharedLinksQuery({ albumId: album.id }), () => getAllSharedLinksQuery({ albumId: album.id }),
() => queryClient, () => queryClient,
); );
const sharedLinks = $derived(query.data ?? []); const sharedLinks = $derived(sharedLinksQuery.data ?? []);
</script> </script>
<OnEvents {onAlbumUserDelete} onAlbumShare={refreshAlbum} onAlbumUpdate={(newAlbum) => (album = newAlbum)} />
<Modal title={readOnly ? $t('album') : $t('options')} {onClose} size="small"> <Modal title={readOnly ? $t('album') : $t('options')} {onClose} size="small">
<ModalBody> <ModalBody>
<Stack gap={6}> <Stack gap={6}>
@@ -67,14 +66,14 @@
{ label: $t('newest_first'), value: AssetOrder.Desc }, { label: $t('newest_first'), value: AssetOrder.Desc },
{ label: $t('oldest_first'), value: AssetOrder.Asc }, { label: $t('oldest_first'), value: AssetOrder.Asc },
]} ]}
onChange={(value) => handleUpdateAlbum(album, { order: value })} onChange={(value) => updateAlbum({ order: value })}
/> />
</Field> </Field>
{/if} {/if}
<Field label={$t('comments_and_likes')} description={$t('let_others_respond')} disabled={readOnly}> <Field label={$t('comments_and_likes')} description={$t('let_others_respond')} disabled={readOnly}>
<Switch <Switch
checked={album.isActivityEnabled} checked={album.isActivityEnabled}
onCheckedChange={(checked) => handleUpdateAlbum(album, { isActivityEnabled: checked })} onCheckedChange={(checked) => updateAlbum({ isActivityEnabled: checked })}
/> />
</Field> </Field>
</div> </div>
+16 -17
View File
@@ -5,19 +5,25 @@
AlbumModalRowType, AlbumModalRowType,
isSelectableRowType, isSelectableRowType,
} from '$lib/components/shared-components/album-selection/album-selection-utils'; } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { createAlbumMutation, getAllAlbumsQuery, getRecentAlbumsQuery } from '$lib/queries/album.query';
import { executeMutation, queryClient } from '$lib/queries/client';
import { albumViewSettings } from '$lib/stores/preferences.store'; import { albumViewSettings } from '$lib/stores/preferences.store';
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk'; import { type AlbumResponseDto } from '@immich/sdk';
import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui'; import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiKeyboardReturn } from '@mdi/js'; import { mdiKeyboardReturn } from '@mdi/js';
import { onMount } from 'svelte'; import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AlbumListItem from '../components/asset-viewer/AlbumListItem.svelte'; import AlbumListItem from '../components/asset-viewer/AlbumListItem.svelte';
import NewAlbumListItem from '../components/shared-components/album-selection/NewAlbumListItem.svelte'; import NewAlbumListItem from '../components/shared-components/album-selection/NewAlbumListItem.svelte';
let albums: AlbumResponseDto[] = $state([]); const queries = {
let recentAlbums: AlbumResponseDto[] = $state([]); albums: createQuery(getAllAlbumsQuery, () => queryClient),
let loading = $state(true); recentAlbums: createQuery(getRecentAlbumsQuery, () => queryClient),
};
const albums = $derived(queries.albums.data ?? []);
const recentAlbums = $derived(queries.recentAlbums.data ?? []);
const loading = $derived(queries.albums.isPending);
let search = $state(''); let search = $state('');
let selectedRowIndex: number = $state(-1); let selectedRowIndex: number = $state(-1);
@@ -28,12 +34,6 @@
let { onClose, selectedItemsCount }: Props = $props(); let { onClose, selectedItemsCount }: Props = $props();
onMount(async () => {
albums = await getAllAlbums({});
recentAlbums = [...albums].sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
loading = false;
});
const multiSelectedAlbumIds: string[] = $state([]); const multiSelectedAlbumIds: string[] = $state([]);
const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0); const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0);
@@ -43,11 +43,10 @@
); );
const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length); const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length);
const onNewAlbum = async (name: string) => { const onNewAlbum = (name: string) =>
const album = await createAlbum({ createAlbumDto: { albumName: name } }); executeMutation(createAlbumMutation, [{ createAlbumDto: { albumName: name } }], {
eventManager.emit('AlbumCreate', album); onSuccess: (album) => onClose([album]),
onClose([album]); });
};
const handleAlbumClick = (album?: AlbumResponseDto) => { const handleAlbumClick = (album?: AlbumResponseDto) => {
if (multiSelectActive) { if (multiSelectActive) {
+16 -7
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte'; import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte'; import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import { handleCreateApiKey } from '$lib/services/api-key.service'; import { createApiKeyMutation } from '$lib/queries/api-key.query';
import { executeMutation } from '$lib/queries/client';
import { Permission } from '@immich/sdk'; import { Permission } from '@immich/sdk';
import { Field, FormModal, Input, modalManager } from '@immich/ui'; import { Field, FormModal, Input, modalManager, toastManager } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js'; import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -16,12 +17,20 @@
const isAllPermissions = $derived(selectedPermissions.length === Object.keys(Permission).length - 1); const isAllPermissions = $derived(selectedPermissions.length === Object.keys(Permission).length - 1);
const onSubmit = async () => { const onSubmit = async () => {
const permissions = isAllPermissions ? [Permission.All] : selectedPermissions; if (!name) {
const response = await handleCreateApiKey({ name, permissions }); toastManager.warning($t('api_key_empty'));
if (response) { return;
await modalManager.show(ApiKeySecretModal, { secret: response.secret });
onClose();
} }
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
const permissions = isAllPermissions ? [Permission.All] : selectedPermissions;
await executeMutation(createApiKeyMutation, [{ apiKeyCreateDto: { name, permissions } }], {
onSuccess: ({ secret }) => void modalManager.show(ApiKeySecretModal, { secret }).then(onClose),
});
}; };
</script> </script>
+16 -8
View File
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte'; import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import { handleUpdateApiKey } from '$lib/services/api-key.service'; import { updateApiKeyMutation } from '$lib/queries/api-key.query';
import { executeMutation } from '$lib/queries/client';
import { Permission } from '@immich/sdk'; import { Permission } from '@immich/sdk';
import { Field, FormModal, Input } from '@immich/ui'; import { Field, FormModal, Input, toastManager } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js'; import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -24,13 +25,20 @@
let selectedPermissions = $state<Permission[]>(mapPermissions(apiKey.permissions)); let selectedPermissions = $state<Permission[]>(mapPermissions(apiKey.permissions));
const onSubmit = async () => { const onSubmit = async () => {
const success = await handleUpdateApiKey(apiKey, { if (!name) {
name, toastManager.warning($t('api_key_empty'));
permissions: isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions, return;
});
if (success) {
onClose();
} }
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
const permissions = isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions;
await executeMutation(updateApiKeyMutation, [{ id: apiKey.id, apiKeyUpdateDto: { name, permissions } }], {
onSuccess: onClose,
});
}; };
</script> </script>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte'; import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { addAssetsToAlbums } from '$lib/services/album.service'; import { addAssetsToAlbumsMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { type AlbumResponseDto } from '@immich/sdk'; import { type AlbumResponseDto } from '@immich/sdk';
type Props = { type Props = {
@@ -17,10 +18,7 @@
return; return;
} }
const success = await addAssetsToAlbums(albumIds, assetIds, { notify: true }); await executeMutation(addAssetsToAlbumsMutation, { albumIds, assetIds }, { onSuccess: onClose });
if (success) {
onClose();
}
}; };
</script> </script>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { handleCreateApiKey } from '$lib/services/api-key.service'; import { createApiKeyMutation } from '$lib/queries/api-key.query';
import { executeMutation } from '$lib/queries/client';
import { Permission } from '@immich/sdk'; import { Permission } from '@immich/sdk';
import { Button, Field, Input, Modal, ModalBody, obtainiumBadge, Select, Text } from '@immich/ui'; import { Button, Field, Input, Modal, ModalBody, obtainiumBadge, Select, Text } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -16,12 +17,12 @@
let { onClose }: Props = $props(); let { onClose }: Props = $props();
const handleCreate = async () => { const handleCreate = () =>
const response = await handleCreateApiKey({ name: 'Obtainium', permissions: [Permission.ServerApkLinks] }); executeMutation(
if (response) { createApiKeyMutation,
inputApiKey = response.secret; [{ apiKeyCreateDto: { name: 'Obtainium', permissions: [Permission.ServerApkLinks] } }],
} { onSuccess: ({ secret }) => (inputApiKey = secret) },
}; );
</script> </script>
<Modal title={$t('obtainium_configurator')} size="medium" {onClose}> <Modal title={$t('obtainium_configurator')} size="medium" {onClose}>
+5 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { handleUpdatePasskey } from '$lib/services/passkey.service'; import { executeMutation } from '$lib/queries/client';
import { updatePasskeyMutation } from '$lib/queries/passkey.query';
import { Field, FormModal, Input } from '@immich/ui'; import { Field, FormModal, Input } from '@immich/ui';
import { mdiShieldKeyOutline } from '@mdi/js'; import { mdiShieldKeyOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -14,10 +15,9 @@
let name = $state(passkey.name ?? ''); let name = $state(passkey.name ?? '');
const onSubmit = async () => { const onSubmit = async () => {
const success = await handleUpdatePasskey(passkey, { name }); await executeMutation(updatePasskeyMutation, [{ id: passkey.id, passkeyUpdateDto: { name: name || null } }], {
if (success) { onSuccess: onClose,
onClose(); });
}
}; };
</script> </script>
+219 -3
View File
@@ -1,8 +1,224 @@
import { getAlbumInfo } from '@immich/sdk'; import {
import { queryOptions } from '@tanstack/svelte-query'; addAssetsToAlbum,
addAssetsToAlbums,
addUsersToAlbum,
AlbumUserRole,
BulkIdErrorReason,
createAlbum,
deleteAlbum,
getAlbumInfo,
getAlbumMapMarkers,
getAlbumStatistics,
getAllAlbums,
removeAssetFromAlbum,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type AlbumsAddAssetsResponseDto,
type BulkIdResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mutationOptions, queryOptions, type MutationMeta } from '@tanstack/svelte-query';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { asMutation, queryClient } from '$lib/queries/client';
import { Route } from '$lib/route';
import { getFormatter } from '$lib/utils/i18n';
type AlbumsFilter = { isOwned?: boolean; isShared?: boolean };
export const albumsQueryKey = ['albums'];
const isOwned = (album: AlbumResponseDto) =>
authManager.authenticated &&
album.albumUsers.some(({ role, user }) => role === AlbumUserRole.Owner && user.id === authManager.user.id);
const matchesFilter = (filter: AlbumsFilter) => (album: AlbumResponseDto) =>
(filter.isOwned === undefined || isOwned(album) === filter.isOwned) &&
(filter.isShared === undefined || album.shared === filter.isShared);
const findOrFail = (albums: AlbumResponseDto[], id: string) => {
const album = albums.find((album) => album.id === id);
if (!album) {
throw new Error(`Album ${id} not found`);
}
return album;
};
export const getAllAlbumsQuery = (filter: AlbumsFilter = {}) =>
queryOptions({
queryKey: albumsQueryKey,
queryFn: ({ signal }) => getAllAlbums({}, { signal }),
select: (albums) => albums.filter(matchesFilter(filter)),
});
export const getRecentAlbumsQuery = () =>
queryOptions({
...getAllAlbumsQuery(),
select: (albums) =>
[...albums].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()).slice(0, 3),
});
export const getAlbumQuery = (id: string) =>
queryOptions({
...getAllAlbumsQuery(),
select: (albums) => findOrFail(albums, id),
});
export const getAlbumsByAssetQuery = (assetId: string) =>
queryOptions({
queryKey: [...albumsQueryKey, 'asset', assetId],
queryFn: ({ signal }) => getAllAlbums({ assetId }, { signal }),
});
export const getAlbumStatisticsQuery = () =>
queryOptions({
queryKey: [...albumsQueryKey, 'statistics'],
queryFn: ({ signal }) => getAlbumStatistics({ signal }),
});
export const getAlbumInfoQuery = (id: string) => export const getAlbumInfoQuery = (id: string) =>
queryOptions({ queryOptions({
queryKey: ['album', id], queryKey: ['album', id],
queryFn: ({ signal }) => getAlbumInfo({ id }, { signal }), queryFn: ({ signal }) => getAlbumInfo({ ...authManager.params, id }, { signal }),
});
export const getAlbumMapMarkersQuery = (id: string) =>
queryOptions({
queryKey: ['album', id, 'map-markers'],
queryFn: ({ signal }) => getAlbumMapMarkers({ ...authManager.params, id }, { signal }),
});
export const invalidateAlbums = () => queryClient.invalidateQueries({ queryKey: albumsQueryKey });
export const invalidateAlbum = (id: string) =>
queryClient.invalidateQueries({ queryKey: getAlbumInfoQuery(id).queryKey, exact: true });
const onAlbumChange = async (album: AlbumResponseDto) => {
queryClient.setQueryData(getAlbumInfoQuery(album.id).queryKey, album);
await Promise.all([invalidateAlbum(album.id), invalidateAlbums()]);
};
const onAlbumAssetsChange = (ids: string[]) =>
Promise.all([...ids.map((id) => invalidateAlbum(id)), invalidateAlbums()]);
export const createAlbumMutation = () =>
asMutation(createAlbum, {
onSuccess: onAlbumChange,
meta: { error: { toast: 'errors.failed_to_create_album' } },
});
const defaultMeta: MutationMeta = {
success: { toast: 'album_info_updated' },
error: { toast: 'errors.unable_to_update_album_info' },
};
export const updateAlbumInfoMutation = (meta?: MutationMeta) =>
asMutation(updateAlbumInfo, { onSuccess: onAlbumChange, meta: meta ?? defaultMeta });
export const deleteAlbumMutation = (meta?: MutationMeta) =>
asMutation(deleteAlbum, {
onSuccess: (_, [{ id }]) => {
queryClient.removeQueries({ queryKey: ['album', id] });
return invalidateAlbums();
},
meta: meta ?? { error: { toast: 'errors.unable_to_delete_album' } },
});
const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: string[], results: BulkIdResponseDto[]) => {
const successCount = results.filter(({ success }) => success).length;
const duplicateCount = results.filter(({ error }) => error === 'duplicate').length;
let description: string | undefined;
if (duplicateCount === assetIds.length) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
} else if (successCount === assetIds.length) {
description = $t('assets_added_to_album_count', { values: { count: successCount } });
} else if (successCount > 0) {
description = $t('assets_added_to_album_partial_count', { values: { successCount, totalCount: assetIds.length } });
}
const button = { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id: albumId })) };
if (description) {
toastManager.primary({ description, button }, { timeout: 5000 });
return;
}
toastManager.danger(
{ description: $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }), button },
{ timeout: 5000 },
);
};
const notifyAddToAlbums = (
$t: MessageFormatter,
albumIds: string[],
assetIds: string[],
results: AlbumsAddAssetsResponseDto,
) => {
if (results.error === BulkIdErrorReason.Duplicate) {
toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }));
} else if (results.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
} else {
toastManager.primary(
$t('assets_added_to_albums_count', {
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
}),
);
}
};
export const addAssetsToAlbumsMutation = ({ notify = true }: { notify?: boolean } = {}) =>
mutationOptions({
mutationFn: ({
albumIds,
assetIds,
}: {
albumIds: string[];
assetIds: string[];
}): Promise<BulkIdResponseDto[] | AlbumsAddAssetsResponseDto> =>
albumIds.length === 1
? addAssetsToAlbum({ ...authManager.params, id: albumIds[0], bulkIdsDto: { ids: assetIds } })
: addAssetsToAlbums({ ...authManager.params, albumsAddAssetsDto: { albumIds, assetIds } }),
onSuccess: async (results, { albumIds, assetIds }) => {
eventManager.emit('AlbumAddAssets', { assetIds, albumIds });
if (notify) {
const $t = await getFormatter();
if (Array.isArray(results)) {
notifyAddToAlbum($t, albumIds[0], assetIds, results);
} else {
notifyAddToAlbums($t, albumIds, assetIds, results);
}
}
await onAlbumAssetsChange(albumIds);
},
meta: { error: { toast: 'errors.error_adding_assets_to_album' } },
});
export const removeAssetFromAlbumMutation = () =>
asMutation(removeAssetFromAlbum, {
onSuccess: (_, [{ id }]) => onAlbumAssetsChange([id]),
meta: { error: { toast: 'errors.error_removing_assets_from_album' } },
});
export const addUsersToAlbumMutation = () =>
asMutation(addUsersToAlbum, {
onSuccess: onAlbumChange,
meta: { error: { toast: 'errors.error_adding_users_to_album' } },
});
export const updateAlbumUserMutation = () =>
asMutation(updateAlbumUser, {
onSuccess: (_, [{ id }]) => onAlbumAssetsChange([id]),
meta: { error: { toast: 'errors.unable_to_change_album_user_role' } },
});
export const removeUserFromAlbumMutation = () =>
asMutation(removeUserFromAlbum, {
onSuccess: (_, [{ id }]) => onAlbumAssetsChange([id]),
meta: { error: { toast: 'errors.unable_to_remove_album_users' } },
}); });
+39
View File
@@ -0,0 +1,39 @@
import { createApiKey, deleteApiKey, getApiKeys, rotateApiKey, updateApiKey } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import { asMutation, queryClient } from '$lib/queries/client';
export const getApiKeysQuery = () =>
queryOptions({
queryKey: ['api-keys'],
queryFn: ({ signal }) => getApiKeys({ signal }),
});
export const createApiKeyMutation = () =>
asMutation(createApiKey, {
onSuccess: () => queryClient.invalidateQueries({ queryKey: getApiKeysQuery().queryKey }),
meta: { error: { toast: 'errors.unable_to_create_api_key' } },
});
export const updateApiKeyMutation = () =>
asMutation(updateApiKey, {
onSuccess: (apiKey) =>
queryClient.setQueryData(getApiKeysQuery().queryKey, (keys) =>
keys?.map((item) => (item.id === apiKey.id ? apiKey : item)),
),
meta: { success: { toast: 'saved_api_key' }, error: { toast: 'errors.unable_to_save_api_key' } },
});
export const rotateApiKeyMutation = () =>
asMutation(rotateApiKey, {
onSuccess: ({ apiKey }) =>
queryClient.setQueryData(getApiKeysQuery().queryKey, (keys) =>
keys?.map((item) => (item.id === apiKey.id ? apiKey : item)),
),
});
export const deleteApiKeyMutation = () =>
asMutation(deleteApiKey, {
onSuccess: (_, [{ id }]) =>
queryClient.setQueryData(getApiKeysQuery().queryKey, (keys) => keys?.filter((key) => key.id !== id)),
meta: { error: { toast: 'errors.unable_to_remove_api_key' } },
});
+14 -2
View File
@@ -29,11 +29,18 @@ declare module '@tanstack/svelte-query' {
interface Register { interface Register {
mutationMeta: { mutationMeta: {
success?: { toast?: ToastMeta }; success?: { toast?: ToastMeta };
error?: { toast?: Translations }; error?: { toast?: Translations | false };
}; };
} }
} }
export class MutationCancelledError extends Error {
constructor() {
super('Mutation cancelled');
this.name = 'MutationCancelledError';
}
}
const createQueryClient = () => { const createQueryClient = () => {
const client = new QueryClient({ const client = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -63,8 +70,13 @@ const createQueryClient = () => {
toastManager[toast.color]($t(toast.description)); toastManager[toast.color]($t(toast.description));
}, },
onError: async (error, _variables, _onMutateResult, { meta }) => { onError: async (error, _variables, _onMutateResult, { meta }) => {
const toast = meta?.error?.toast;
if (toast === false || error instanceof MutationCancelledError) {
return;
}
const $t = await getFormatter(); const $t = await getFormatter();
handleError(error, $t(meta?.error?.toast ?? 'errors.something_went_wrong')); handleError(error, $t(toast ?? 'errors.something_went_wrong'));
}, },
}), }),
}); });
+76
View File
@@ -0,0 +1,76 @@
import {
deletePasskey,
finishAuthentication,
finishRegistration,
searchPasskeys,
startAuthentication,
startRegistration,
updatePasskey,
} from '@immich/sdk';
import {
startAuthentication as startBrowserAuthentication,
startRegistration as startBrowserRegistration,
type PublicKeyCredentialCreationOptionsJSON,
type PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/browser';
import { mutationOptions, queryOptions } from '@tanstack/svelte-query';
import { asMutation, MutationCancelledError, queryClient } from '$lib/queries/client';
const isPasskeyCancelled = (error: unknown) => error instanceof Error && error.name === 'NotAllowedError';
const withPasskeyPrompt = async <T>(flow: () => Promise<T>) => {
try {
return await flow();
} catch (error) {
throw isPasskeyCancelled(error) ? new MutationCancelledError() : error;
}
};
export const searchPasskeysQuery = () =>
queryOptions({
queryKey: ['passkeys'],
queryFn: ({ signal }) => searchPasskeys({}, { signal }),
});
export const createPasskeyMutation = () =>
mutationOptions({
mutationFn: () =>
withPasskeyPrompt(async () => {
const options = await startRegistration();
const response = await startBrowserRegistration({
optionsJSON: options as PublicKeyCredentialCreationOptionsJSON,
});
return finishRegistration({ passkeyRegistrationFinishDto: { response } });
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: searchPasskeysQuery().queryKey, exact: true }),
});
export const loginWithPasskeyMutation = () =>
mutationOptions({
mutationFn: () =>
withPasskeyPrompt(async () => {
const options = await startAuthentication();
const response = await startBrowserAuthentication({
optionsJSON: options as PublicKeyCredentialRequestOptionsJSON,
});
return finishAuthentication({ passkeyAuthenticationFinishDto: { response } });
}),
meta: { error: { toast: false } },
});
export const updatePasskeyMutation = () =>
asMutation(updatePasskey, {
onSuccess: (passkey) =>
queryClient.setQueryData(searchPasskeysQuery().queryKey, (passkeys) =>
passkeys?.map((item) => (item.id === passkey.id ? passkey : item)),
),
meta: { success: { toast: true } },
});
export const deletePasskeyMutation = () =>
asMutation(deletePasskey, {
onSuccess: (_, [{ id }]) =>
queryClient.setQueryData(searchPasskeysQuery().queryKey, (passkeys) =>
passkeys?.filter((passkey) => passkey.id !== id),
),
});
+22
View File
@@ -0,0 +1,22 @@
import { deleteAllSessions, deleteSession, getSessions } from '@immich/sdk';
import { mutationOptions, queryOptions } from '@tanstack/svelte-query';
import { asMutation, queryClient } from '$lib/queries/client';
export const getSessionsQuery = () =>
queryOptions({
queryKey: ['sessions'],
queryFn: ({ signal }) => getSessions({ signal }),
});
export const deleteSessionMutation = () =>
asMutation(deleteSession, {
onSettled: () => queryClient.invalidateQueries({ queryKey: getSessionsQuery().queryKey }),
meta: { success: { toast: 'logged_out_device' }, error: { toast: 'errors.unable_to_log_out_device' } },
});
export const deleteAllSessionsMutation = () =>
mutationOptions({
mutationFn: () => deleteAllSessions(),
onSettled: () => queryClient.invalidateQueries({ queryKey: getSessionsQuery().queryKey }),
meta: { success: { toast: 'logged_out_all_devices' }, error: { toast: 'errors.unable_to_log_out_all_devices' } },
});
+9 -5
View File
@@ -7,7 +7,7 @@ import {
type SharedLinkResponseDto, type SharedLinkResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query'; import { queryOptions } from '@tanstack/svelte-query';
import { getAlbumInfoQuery } from '$lib/queries/album.query'; import { invalidateAlbum, invalidateAlbums } from '$lib/queries/album.query';
import { asMutation, queryClient } from '$lib/queries/client'; import { asMutation, queryClient } from '$lib/queries/client';
const findOrFail = (sharedLinks: SharedLinkResponseDto[], id: string) => { const findOrFail = (sharedLinks: SharedLinkResponseDto[], id: string) => {
@@ -31,15 +31,19 @@ export const getSharedLinkQuery = (id: string) =>
select: (sharedLinks) => findOrFail(sharedLinks, id), select: (sharedLinks) => findOrFail(sharedLinks, id),
}); });
const invalidateAlbum = (albumId?: string | null) => const onAlbumSharingChange = async (albumId?: string | null) => {
albumId ? queryClient.invalidateQueries({ queryKey: getAlbumInfoQuery(albumId).queryKey }) : undefined; await invalidateAlbums();
if (albumId) {
await invalidateAlbum(albumId);
}
};
export const createSharedLinkMutation = () => export const createSharedLinkMutation = () =>
asMutation(createSharedLink, { asMutation(createSharedLink, {
onSuccess: async (_, [{ sharedLinkCreateDto }]) => { onSuccess: async (_, [{ sharedLinkCreateDto }]) => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: getAllSharedLinksQuery().queryKey, exact: true }), queryClient.invalidateQueries({ queryKey: getAllSharedLinksQuery().queryKey, exact: true }),
invalidateAlbum(sharedLinkCreateDto.albumId), onAlbumSharingChange(sharedLinkCreateDto.albumId),
]); ]);
}, },
meta: { error: { toast: 'errors.failed_to_create_shared_link' } }, meta: { error: { toast: 'errors.failed_to_create_shared_link' } },
@@ -60,7 +64,7 @@ export const removeSharedLinkMutation = () =>
queryClient.setQueryData(getAllSharedLinksQuery().queryKey, (sharedLinks) => queryClient.setQueryData(getAllSharedLinksQuery().queryKey, (sharedLinks) =>
sharedLinks?.filter((sharedLink) => sharedLink.id !== id), sharedLinks?.filter((sharedLink) => sharedLink.id !== id),
); );
await invalidateAlbum(albumId); await onAlbumSharingChange(albumId);
}, },
meta: { success: { toast: 'deleted_shared_link' }, error: { toast: 'errors.unable_to_delete_shared_link' } }, meta: { success: { toast: 'deleted_shared_link' }, error: { toast: 'errors.unable_to_delete_shared_link' } },
}); });
+47 -176
View File
@@ -1,21 +1,5 @@
import { import { AlbumUserRole, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk';
addAssetsToAlbum as addToAlbum, import { modalManager, type ActionItem } from '@immich/ui';
addAssetsToAlbums as addToAlbums,
addUsersToAlbum,
AlbumUserRole,
BulkIdErrorReason,
deleteAlbum,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type AlbumsAddAssetsResponseDto,
type AssetResponseDto,
type BulkIdResponseDto,
type UpdateAlbumDto,
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { import {
mdiDownload, mdiDownload,
mdiImageOutline, mdiImageOutline,
@@ -31,19 +15,26 @@ import {
} from '@mdi/js'; } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n'; import { type MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AddUsersModal from '$lib/modals/AddUsersModal.svelte'; import AddUsersModal from '$lib/modals/AddUsersModal.svelte';
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte'; import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte'; import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import {
addAssetsToAlbumsMutation,
addUsersToAlbumMutation,
deleteAlbumMutation,
removeUserFromAlbumMutation,
updateAlbumInfoMutation,
updateAlbumUserMutation,
} from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { userInteraction } from '$lib/stores/user.svelte';
import { createAlbumAndRedirect } from '$lib/utils/album-utils'; import { createAlbumAndRedirect } from '$lib/utils/album-utils';
import { downloadArchive } from '$lib/utils/asset-utils'; import { downloadArchive } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
export const isAlbumOwner = (album: AlbumResponseDto) => album.albumUsers[0].user.id === authManager.user.id; export const isAlbumOwner = (album: AlbumResponseDto) => album.albumUsers[0].user.id === authManager.user.id;
@@ -129,7 +120,7 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
return { AddUsers, CreateSharedLink, Delete, Download, Edit, Leave, Options, Share }; return { AddUsers, CreateSharedLink, Delete, Download, Edit, Leave, Options, Share };
}; };
export const getAlbumAssetActions = ($t: MessageFormatter, album: AlbumResponseDto, asset: AssetResponseDto) => { export const getAlbumAssetActions = ($t: MessageFormatter, album: AlbumResponseDto, asset: { id: string }) => {
const SetCover: ActionItem = { const SetCover: ActionItem = {
title: $t('set_as_album_cover'), title: $t('set_as_album_cover'),
icon: mdiImageOutline, icon: mdiImageOutline,
@@ -146,11 +137,7 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse
icon: mdiPlusBoxOutline, icon: mdiPlusBoxOutline,
$if: () => assets.length > 0, $if: () => assets.length > 0,
onAction: () => onAction: () =>
addAssetsToAlbums( executeMutation(addAssetsToAlbumsMutation, { albumIds: [album.id], assetIds: assets.map(({ id }) => id) }),
[album.id],
assets.map(({ id }) => id),
{ notify: true },
).then(() => undefined),
}; };
const Upload: ActionItem = { const Upload: ActionItem = {
@@ -163,78 +150,13 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse
return { AddAssets, Upload }; return { AddAssets, Upload };
}; };
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], { notify }: { notify: boolean }) => { const leaveAlbumPage = (album: AlbumResponseDto) => {
const $t = await getFormatter(); if (page.params.albumId === album.id) {
void goto(Route.albums());
try {
if (albumIds.length === 1) {
const albumId = albumIds[0];
const results = await addToAlbum({ ...authManager.params, id: albumId, bulkIdsDto: { ids: assetIds } });
if (notify) {
notifyAddToAlbum($t, albumId, assetIds, results);
}
}
if (albumIds.length > 1) {
const results = await addToAlbums({ ...authManager.params, albumsAddAssetsDto: { albumIds, assetIds } });
if (notify) {
notifyAddToAlbums($t, albumIds, assetIds, results);
}
}
eventManager.emit('AlbumAddAssets', { assetIds, albumIds });
return true;
} catch (error) {
handleError(error, $t('errors.error_adding_assets_to_album'));
return false;
} }
}; };
const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: string[], results: BulkIdResponseDto[]) => { export const handleUpdateUserAlbumRole = ({
const successCount = results.filter(({ success }) => success).length;
const duplicateCount = results.filter(({ error }) => error === 'duplicate').length;
let description: string | undefined;
if (duplicateCount === assetIds.length) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
} else if (successCount === assetIds.length) {
description = $t('assets_added_to_album_count', { values: { count: successCount } });
} else if (successCount > 0) {
description = $t('assets_added_to_album_partial_count', { values: { successCount, totalCount: assetIds.length } });
}
const button = { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id: albumId })) };
if (description) {
toastManager.primary({ description, button }, { timeout: 5000 });
return;
}
toastManager.danger(
{ description: $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }), button },
{ timeout: 5000 },
);
};
const notifyAddToAlbums = (
$t: MessageFormatter,
albumIds: string[],
assetIds: string[],
results: AlbumsAddAssetsResponseDto,
) => {
if (results.error === BulkIdErrorReason.Duplicate) {
toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }));
} else if (results.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
} else {
toastManager.primary(
$t('assets_added_to_albums_count', {
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
}),
);
}
};
export const handleUpdateUserAlbumRole = async ({
albumId, albumId,
userId, userId,
role, role,
@@ -242,28 +164,14 @@ export const handleUpdateUserAlbumRole = async ({
albumId: string; albumId: string;
userId: string; userId: string;
role: AlbumUserRole; role: AlbumUserRole;
}) => { }) => executeMutation(updateAlbumUserMutation, [{ id: albumId, userId, updateAlbumUserDto: { role } }]);
const $t = await getFormatter();
try { export const handleAddUsersToAlbum = (album: AlbumResponseDto, users: UserResponseDto[], onSuccess?: () => void) =>
await updateAlbumUser({ id: albumId, userId, updateAlbumUserDto: { role } }); executeMutation(
eventManager.emit('AlbumUserUpdate', { albumId, userId, role }); addUsersToAlbumMutation,
} catch (error) { [{ id: album.id, addUsersDto: { albumUsers: users.map(({ id }) => ({ userId: id })) } }],
handleError(error, $t('errors.unable_to_change_album_user_role')); { onSuccess },
} );
};
export const handleAddUsersToAlbum = async (album: AlbumResponseDto, users: UserResponseDto[]) => {
const $t = await getFormatter();
try {
await addUsersToAlbum({ id: album.id, addUsersDto: { albumUsers: users.map(({ id }) => ({ userId: id })) } });
eventManager.emit('AlbumShare');
return true;
} catch (error) {
handleError(error, $t('errors.error_adding_users_to_album'));
}
};
export const handleRemoveUserFromAlbum = async (album: AlbumResponseDto, albumUser: UserResponseDto) => { export const handleRemoveUserFromAlbum = async (album: AlbumResponseDto, albumUser: UserResponseDto) => {
const $t = await getFormatter(); const $t = await getFormatter();
@@ -278,12 +186,7 @@ export const handleRemoveUserFromAlbum = async (album: AlbumResponseDto, albumUs
return; return;
} }
try { await executeMutation(removeUserFromAlbumMutation, [{ id: album.id, userId: albumUser.id }]);
await removeUserFromAlbum({ id: album.id, userId: albumUser.id });
eventManager.emit('AlbumUserDelete', { albumId: album.id, userId: albumUser.id });
} catch (error) {
handleError(error, $t('errors.unable_to_remove_album_users'));
}
}; };
export const handleLeaveAlbum = async (album: AlbumResponseDto) => { export const handleLeaveAlbum = async (album: AlbumResponseDto) => {
@@ -299,49 +202,20 @@ export const handleLeaveAlbum = async (album: AlbumResponseDto) => {
return; return;
} }
try { await executeMutation(removeUserFromAlbumMutation, [{ id: album.id, userId: 'me' }], {
await removeUserFromAlbum({ id: album.id, userId: 'me' }); onSuccess: () => leaveAlbumPage(album),
userInteraction.recentAlbums = undefined; });
eventManager.emit('AlbumDelete', album);
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_remove_album_users'));
}
}; };
const handleUpdateThumbnail = async (album: AlbumResponseDto, assetId: string) => { const handleUpdateThumbnail = (album: AlbumResponseDto, assetId: string) =>
const $t = await getFormatter(); executeMutation(
() =>
try { updateAlbumInfoMutation({
const response = await updateAlbumInfo({ success: { toast: 'album_cover_updated' },
id: album.id, error: { toast: 'errors.unable_to_update_album_cover' },
updateAlbumDto: { }),
albumThumbnailAssetId: assetId, [{ id: album.id, updateAlbumDto: { albumThumbnailAssetId: assetId } }],
}, );
});
eventManager.emit('AlbumUpdate', response);
toastManager.primary($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
};
export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbumDto) => {
const $t = await getFormatter();
try {
const response = await updateAlbumInfo({ id, updateAlbumDto: dto });
eventManager.emit('AlbumUpdate', response);
toastManager.primary({
description: $t('album_info_updated'),
button: { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id })) },
});
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_info'));
}
};
export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { prompt?: boolean; notify?: boolean }) => { export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { prompt?: boolean; notify?: boolean }) => {
const $t = await getFormatter(); const $t = await getFormatter();
@@ -355,21 +229,18 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
const description = $t('album_delete_confirmation_description'); const description = $t('album_delete_confirmation_description');
const success = await modalManager.showDialog({ prompt: `${confirmation} ${description}` }); const success = await modalManager.showDialog({ prompt: `${confirmation} ${description}` });
if (!success) { if (!success) {
return false; return;
} }
} }
try { await executeMutation(
await deleteAlbum({ id: album.id }); () =>
eventManager.emit('AlbumDelete', album); deleteAlbumMutation(
if (notify) { notify ? { success: { toast: true }, error: { toast: 'errors.unable_to_delete_album' } } : {},
toastManager.primary(); ),
} [{ id: album.id }],
return true; { onSuccess: () => leaveAlbumPage(album) },
} catch (error) { );
handleError(error, $t('errors.unable_to_delete_album'), { notify });
return false;
}
}; };
export const handleDownloadAlbum = async (album: AlbumResponseDto) => { export const handleDownloadAlbum = async (album: AlbumResponseDto) => {
+11 -73
View File
@@ -1,20 +1,12 @@
import { import type { ApiKeyResponseDto } from '@immich/sdk';
createApiKey,
deleteApiKey,
rotateApiKey,
updateApiKey,
type ApiKeyCreateDto,
type ApiKeyResponseDto,
type ApiKeyUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiAutorenew, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import { mdiAutorenew, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n'; import type { MessageFormatter } from 'svelte-i18n';
import { eventManager } from '$lib/managers/event-manager.svelte';
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte'; import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte'; import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte'; import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
import { handleError } from '$lib/utils/handle-error'; import { deleteApiKeyMutation, rotateApiKeyMutation } from '$lib/queries/api-key.query';
import { executeMutation } from '$lib/queries/client';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
export const getApiKeysActions = ($t: MessageFormatter) => { export const getApiKeysActions = ($t: MessageFormatter) => {
@@ -49,53 +41,7 @@ export const getApiKeyActions = ($t: MessageFormatter, apiKey: ApiKeyResponseDto
return { Update, Rotate, Delete }; return { Update, Rotate, Delete };
}; };
export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => { const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter();
try {
if (!dto.name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (dto.permissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
const response = await createApiKey({ apiKeyCreateDto: dto });
eventManager.emit('ApiKeyCreate', response);
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
}
};
export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpdateDto) => {
const $t = await getFormatter();
if (!dto.name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (dto.permissions && dto.permissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
const response = await updateApiKey({ id: apiKey.id, apiKeyUpdateDto: dto });
eventManager.emit('ApiKeyUpdate', response);
toastManager.primary($t('saved_api_key'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
}
};
export const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter(); const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('rotate_api_key_prompt') }); const confirmed = await modalManager.showDialog({ prompt: $t('rotate_api_key_prompt') });
@@ -103,16 +49,12 @@ export const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => {
return; return;
} }
try { await executeMutation(rotateApiKeyMutation, [{ id: apiKey.id }], {
const response = await rotateApiKey({ id: apiKey.id }); onSuccess: ({ secret }) => void modalManager.show(ApiKeySecretModal, { secret }),
eventManager.emit('ApiKeyUpdate', response); });
await modalManager.show(ApiKeySecretModal, { secret: response.secret });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
}; };
export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => { const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter(); const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') }); const confirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
@@ -120,11 +62,7 @@ export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
return; return;
} }
try { await executeMutation(deleteApiKeyMutation, [{ id: apiKey.id }], {
await deleteApiKey({ id: apiKey.id }); onSuccess: () => toastManager.primary($t('removed_api_key', { values: { name: apiKey.name } })),
eventManager.emit('ApiKeyDelete', apiKey); });
toastManager.primary($t('removed_api_key', { values: { name: apiKey.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
}
}; };
+9 -15
View File
@@ -5,7 +5,6 @@ import {
AssetVisibility, AssetVisibility,
bulkTagAssets, bulkTagAssets,
getAssetInfo, getAssetInfo,
removeAssetFromAlbum,
runAssetJobs, runAssetJobs,
updateAsset, updateAsset,
type AlbumResponseDto, type AlbumResponseDto,
@@ -52,7 +51,8 @@ import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte'; import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { readPreloadedQuery } from '$lib/queries/client'; import { removeAssetFromAlbumMutation } from '$lib/queries/album.query';
import { executeMutation, readPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query'; import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -518,19 +518,13 @@ const handleBulkRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumR
const handleRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumResponseDto) => { const handleRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumResponseDto) => {
const $t = await getFormatter(); const $t = await getFormatter();
try { await executeMutation(removeAssetFromAlbumMutation, [{ id: album.id, bulkIdsDto: { ids: assetIds } }], {
const results = await removeAssetFromAlbum({ onSuccess: (results) => {
id: album.id, const count = results.filter(({ success }) => success).length;
bulkIdsDto: { ids: assetIds }, toastManager.primary($t('assets_removed_count', { values: { count } }));
}); eventManager.emit('AlbumRemoveAssets', { assetIds, albumIds: [album.id] });
},
const count = results.filter(({ success }) => success).length; });
toastManager.primary($t('assets_removed_count', { values: { count } }));
eventManager.emit('AlbumRemoveAssets', { assetIds, albumIds: [album.id] });
} catch (error) {
handleError(error, $t('errors.error_removing_assets_from_album'));
}
}; };
const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => { const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => {
+15 -82
View File
@@ -1,47 +1,22 @@
import { import { type PasskeyResponseDto } from '@immich/sdk';
deletePasskey,
finishAuthentication,
finishRegistration,
startAuthentication,
startRegistration,
updatePasskey,
type PasskeyResponseDto,
type PasskeyUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import { import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
browserSupportsWebAuthn,
startAuthentication as startBrowserAuthentication,
startRegistration as startBrowserRegistration,
type PublicKeyCredentialCreationOptionsJSON,
type PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/browser';
import type { MessageFormatter } from 'svelte-i18n'; import type { MessageFormatter } from 'svelte-i18n';
import { eventManager } from '$lib/managers/event-manager.svelte';
import PasskeyUpdateModal from '$lib/modals/PasskeyUpdateModal.svelte'; import PasskeyUpdateModal from '$lib/modals/PasskeyUpdateModal.svelte';
import { handleError } from '$lib/utils/handle-error'; import { executeMutation } from '$lib/queries/client';
import { createPasskeyMutation, deletePasskeyMutation } from '$lib/queries/passkey.query';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
export const isPasskeySupported = () => browserSupportsWebAuthn();
export const isPasskeyCancelled = (error: unknown) => error instanceof Error && error.name === 'NotAllowedError';
export const getPasskeysActions = ($t: MessageFormatter) => { export const getPasskeysActions = ($t: MessageFormatter) => {
const Create: ActionItem = { const Create: ActionItem = {
title: $t('new_passkey'), title: $t('new_passkey'),
icon: mdiPlus, icon: mdiPlus,
onAction: async () => { $if: () => browserSupportsWebAuthn(),
if (!isPasskeySupported()) { onAction: () =>
toastManager.warning($t('passkeys_not_supported')); executeMutation(createPasskeyMutation, undefined, {
return; onSuccess: (passkey) => void modalManager.show(PasskeyUpdateModal, { passkey }),
} }),
const passkey = await handleCreatePasskey();
if (passkey) {
await modalManager.show(PasskeyUpdateModal, { passkey });
}
},
}; };
return { Create }; return { Create };
@@ -63,45 +38,6 @@ export const getPasskeyActions = ($t: MessageFormatter, passkey: PasskeyResponse
return { Update, Delete }; return { Update, Delete };
}; };
const handleCreatePasskey = async () => {
const $t = await getFormatter();
try {
const options = await startRegistration();
const response = await startBrowserRegistration({
optionsJSON: options as PublicKeyCredentialCreationOptionsJSON,
});
const passkey = await finishRegistration({ passkeyRegistrationFinishDto: { response } });
eventManager.emit('PasskeyCreate', passkey);
return passkey;
} catch (error) {
if (isPasskeyCancelled(error)) {
return;
}
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleLoginWithPasskey = async () => {
const options = await startAuthentication();
const response = await startBrowserAuthentication({ optionsJSON: options as PublicKeyCredentialRequestOptionsJSON });
return finishAuthentication({ passkeyAuthenticationFinishDto: { response } });
};
export const handleUpdatePasskey = async (passkey: { id: string }, dto: PasskeyUpdateDto) => {
const $t = await getFormatter();
try {
const response = await updatePasskey({ id: passkey.id, passkeyUpdateDto: { name: dto.name || null } });
eventManager.emit('PasskeyUpdate', response);
toastManager.primary();
return true;
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
const handleDeletePasskey = async (passkey: PasskeyResponseDto) => { const handleDeletePasskey = async (passkey: PasskeyResponseDto) => {
const $t = await getFormatter(); const $t = await getFormatter();
@@ -110,13 +46,10 @@ const handleDeletePasskey = async (passkey: PasskeyResponseDto) => {
return; return;
} }
try { await executeMutation(deletePasskeyMutation, [{ id: passkey.id }], {
await deletePasskey({ id: passkey.id }); onSuccess: () =>
eventManager.emit('PasskeyDelete', passkey); toastManager.primary(
toastManager.primary( $t('removed_passkey', { values: { name: passkey.name ?? $t('passkeys', { values: { count: 1 } }) } }),
$t('removed_passkey', { values: { name: passkey.name ?? $t('passkeys', { values: { count: 1 } }) } }), ),
); });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
}; };
-28
View File
@@ -1,28 +0,0 @@
import type { AlbumResponseDto } from '@immich/sdk';
import { eventManager } from '$lib/managers/event-manager.svelte';
interface UserInteractions {
recentAlbums?: AlbumResponseDto[];
}
const defaultUserInteraction: UserInteractions = {
recentAlbums: undefined,
};
export const userInteraction = $state<UserInteractions>(defaultUserInteraction);
const resetRecentAlbums = () => {
userInteraction.recentAlbums = undefined;
};
const reset = () => {
Object.assign(userInteraction, defaultUserInteraction);
};
// eslint-disable-next-line unicorn/no-top-level-side-effects
eventManager.on({
AlbumCreate: () => resetRecentAlbums(),
AlbumUpdate: () => resetRecentAlbums(),
AlbumDelete: () => resetRecentAlbums(),
AuthLogout: () => reset(),
});
+6 -26
View File
@@ -1,10 +1,9 @@
import type { AlbumResponseDto } from '@immich/sdk'; import type { AlbumResponseDto } from '@immich/sdk';
import * as sdk from '@immich/sdk';
import { orderBy } from 'lodash-es'; import { orderBy } from 'lodash-es';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { createAlbumMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { import {
AlbumFilter, AlbumFilter,
@@ -15,35 +14,16 @@ import {
locale, locale,
type AlbumViewSettings, type AlbumViewSettings,
} from '$lib/stores/preferences.store'; } from '$lib/stores/preferences.store';
import { handleError } from '$lib/utils/handle-error';
/** /**
* ------------------------- * -------------------------
* Albums General Management * Albums General Management
* ------------------------- * -------------------------
*/ */
export const createAlbum = async (name?: string, assetIds?: string[]) => { export const createAlbumAndRedirect = (name?: string, assetIds?: string[]) =>
try { executeMutation(createAlbumMutation, [{ createAlbumDto: { albumName: name ?? '', assetIds } }], {
const newAlbum: AlbumResponseDto = await sdk.createAlbum({ onSuccess: (album) => void goto(Route.viewAlbum(album)),
createAlbumDto: { });
albumName: name ?? '',
assetIds,
},
});
eventManager.emit('AlbumCreate', newAlbum);
return newAlbum;
} catch (error) {
const $t = get(t);
handleError(error, $t('errors.failed_to_create_album'));
}
};
export const createAlbumAndRedirect = async (name?: string, assetIds?: string[]) => {
const newAlbum = await createAlbum(name, assetIds);
if (newAlbum) {
await goto(Route.viewAlbum(newAlbum));
}
};
/** /**
* ------------- * -------------
+6 -2
View File
@@ -12,7 +12,8 @@ import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { uploadManager } from '$lib/managers/upload-manager.svelte'; import { uploadManager } from '$lib/managers/upload-manager.svelte';
import { addAssetsToAlbums } from '$lib/services/album.service'; import { addAssetsToAlbumsMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import { UploadState } from '$lib/types'; import { UploadState } from '$lib/types';
import { uploadRequest } from '$lib/utils'; import { uploadRequest } from '$lib/utils';
@@ -221,7 +222,10 @@ async function fileUploader({
if (albumId && !authManager.isSharedLink) { if (albumId && !authManager.isSharedLink) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbums([albumId], [responseData.id], { notify: false }); await executeMutation(() => addAssetsToAlbumsMutation({ notify: false }), {
albumIds: [albumId],
assetIds: [responseData.id],
});
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
} }
+9 -2
View File
@@ -6,6 +6,8 @@
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import GroupTab from '$lib/elements/GroupTab.svelte'; import GroupTab from '$lib/elements/GroupTab.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte'; import SearchBar from '$lib/elements/SearchBar.svelte';
import { getAllAlbumsQuery } from '$lib/queries/album.query';
import { createPreloadedQuery } from '$lib/queries/client';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store'; import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store';
import { createAlbumAndRedirect } from '$lib/utils/album-utils'; import { createAlbumAndRedirect } from '$lib/utils/album-utils';
@@ -18,6 +20,11 @@
let { data }: Props = $props(); let { data }: Props = $props();
const queries = {
ownedAlbums: createPreloadedQuery(() => getAllAlbumsQuery({ isOwned: true })),
sharedAlbums: createPreloadedQuery(() => getAllAlbumsQuery({ isShared: true })),
};
let searchQuery = $state(''); let searchQuery = $state('');
let albumGroups: string[] = $state([]); let albumGroups: string[] = $state([]);
</script> </script>
@@ -44,8 +51,8 @@
</div> </div>
<Albums <Albums
ownedAlbums={data.albums} ownedAlbums={queries.ownedAlbums.data}
sharedAlbums={data.sharedAlbums} sharedAlbums={queries.sharedAlbums.data}
userSettings={$albumViewSettings} userSettings={$albumViewSettings}
allowEdit allowEdit
{searchQuery} {searchQuery}
+3 -5
View File
@@ -1,17 +1,15 @@
import { getAllAlbums } from '@immich/sdk'; import { getAllAlbumsQuery } from '$lib/queries/album.query';
import { queryClient } from '$lib/queries/client';
import { authenticate } from '$lib/utils/auth'; import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ url }) => { export const load = (async ({ url }) => {
await authenticate(url); await authenticate(url);
const sharedAlbums = await getAllAlbums({ isShared: true }); await queryClient.query(getAllAlbumsQuery());
const albums = await getAllAlbums({ isOwned: true });
const $t = await getFormatter(); const $t = await getFormatter();
return { return {
albums,
sharedAlbums,
meta: { meta: {
title: $t('albums'), title: $t('albums'),
}, },
@@ -27,27 +27,18 @@
import { assetMultiSelectManager, AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte'; import { assetMultiSelectManager, AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
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 { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { getAlbumInfoQuery } from '$lib/queries/album.query';
import { createPreloadedQuery, queryClient } from '$lib/queries/client';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { import { getAlbumActions, getAlbumAssetsActions, isAlbumEditor, isAlbumOwner } from '$lib/services/album.service';
getAlbumActions,
getAlbumAssetsActions,
handleDeleteAlbum,
isAlbumEditor,
isAlbumOwner,
} from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service'; import { getGlobalActions } from '$lib/services/app.service';
import { getAssetBulkActions } from '$lib/services/asset.service'; import { getAssetBulkActions } from '$lib/services/asset.service';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, navigate, type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { isAlbumsRoute, navigate, type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { AlbumUserRole, AssetVisibility, updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk'; import { AlbumUserRole, AssetVisibility } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Icon, IconButton, toastManager } from '@immich/ui'; import { ActionButton, CommandPaletteDefaultProvider, Icon, IconButton } from '@immich/ui';
import { import {
mdiAccountEye, mdiAccountEye,
mdiAccountEyeOutline, mdiAccountEyeOutline,
@@ -68,12 +59,23 @@
import AlbumTitle from './AlbumTitle.svelte'; import AlbumTitle from './AlbumTitle.svelte';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte'; import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import { delay } from '$lib/utils/asset-utils'; import { delay } from '$lib/utils/asset-utils';
import {
deleteAlbumMutation,
getAlbumInfoQuery,
invalidateAlbum,
updateAlbumInfoMutation,
} from '$lib/queries/album.query';
import { createPreloadedQuery, executeMutation } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query'; import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery); let { params }: PageProps = $props();
const config = $derived(query.data);
const { params }: PageProps = $props(); const queries = {
config: createPreloadedQuery(getUserConfigQuery),
album: createPreloadedQuery(() => getAlbumInfoQuery(params.albumId)),
};
const config = $derived(queries.config.data);
const album = $derived(queries.album.data);
let { slideshowState, slideshowNavigation } = slideshowStore; let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined = $state(); let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW); let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
@@ -122,16 +124,7 @@
await goto(Route.albums()); await goto(Route.albums());
}; };
const refreshAlbum = async (updated?: AlbumResponseDto) => { const refreshAlbum = () => invalidateAlbum(album.id);
if (updated) {
if (updated.id !== album.id) {
return;
}
queryClient.setQueryData(getAlbumInfoQuery(album.id).queryKey, updated);
}
await queryClient.invalidateQueries({ queryKey: getAlbumInfoQuery(album.id).queryKey });
};
const setModeToView = async () => { const setModeToView = async () => {
timelineManager.suspendTransitions = true; timelineManager.suspendTransitions = true;
@@ -191,29 +184,22 @@
await updateThumbnail(firstAsset.id); await updateThumbnail(firstAsset.id);
}; };
const updateThumbnail = async (assetId: string) => { const updateThumbnail = (assetId: string) =>
try { executeMutation(
const response = await updateAlbumInfo({ () =>
id: album.id, updateAlbumInfoMutation({
updateAlbumDto: { success: { toast: 'album_cover_updated' },
albumThumbnailAssetId: assetId, error: { toast: 'errors.unable_to_update_album_cover' },
}, }),
}); [{ id: album.id, updateAlbumDto: { albumThumbnailAssetId: assetId } }],
eventManager.emit('AlbumUpdate', response); );
toastManager.primary($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
};
onNavigate(async ({ to }) => { onNavigate(async ({ to }) => {
if (!isAlbumsRoute(to?.route.id) && album.assetCount === 0 && !album.albumName) { if (!isAlbumsRoute(to?.route.id) && album.assetCount === 0 && !album.albumName) {
await handleDeleteAlbum(album, { notify: false, prompt: false }); await executeMutation(() => deleteAlbumMutation({}), [{ id: album.id }]);
} }
}); });
const albumQuery = createPreloadedQuery(() => getAlbumInfoQuery(params.albumId));
let album = $derived(albumQuery.data);
const albumId = $derived(album.id); const albumId = $derived(album.id);
const albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer)); const albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
@@ -274,42 +260,15 @@
viewMode === AlbumPageViewMode.SELECT_ASSETS ? timelineMultiSelectManager : assetMultiSelectManager, viewMode === AlbumPageViewMode.SELECT_ASSETS ? timelineMultiSelectManager : assetMultiSelectManager,
); );
const onAlbumDelete = async ({ id }: AlbumResponseDto) => { const onAlbumAddAssets = async ({ albumIds }: { albumIds: string[]; assetIds: string[] }) => {
if (id !== album.id) {
return;
}
await goto(Route.albums());
viewMode = AlbumPageViewMode.VIEW;
};
const onAlbumAddAssets = async ({ albumIds, assetIds }: { albumIds: string[]; assetIds: string[] }) => {
if (!albumIds.includes(album.id)) { if (!albumIds.includes(album.id)) {
return; return;
} }
album = { ...album, assetCount: album.assetCount + assetIds.length };
timelineMultiSelectManager.clear(); timelineMultiSelectManager.clear();
await setModeToView(); await setModeToView();
}; };
const onAlbumShare = async () => {
await refreshAlbum();
await setModeToView();
};
const onAlbumUserUpdate = ({ albumId, userId, role }: { albumId: string; userId: string; role: AlbumUserRole }) => {
if (albumId !== album.id) {
return;
}
const albumUsers = album.albumUsers.map((albumUser) =>
albumUser.user.id === userId ? { ...albumUser, role } : albumUser,
);
album = { ...album, albumUsers };
};
const { Cast } = $derived(getGlobalActions($t)); const { Cast } = $derived(getGlobalActions($t));
const Actions = $derived(getAlbumActions($t, album)); const Actions = $derived(getAlbumActions($t, album));
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineMultiSelectManager.assets)); const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineMultiSelectManager.assets));
@@ -323,16 +282,7 @@
}); });
</script> </script>
<OnEvents <OnEvents {onAlbumAddAssets} {onAlbumRemoveAssets} onAssetsDelete={() => refreshAlbum()} />
{onAlbumDelete}
{onAlbumAddAssets}
{onAlbumRemoveAssets}
{onAlbumShare}
{onAlbumUserUpdate}
onAlbumUserDelete={() => refreshAlbum()}
onAlbumUpdate={(album) => refreshAlbum(album)}
onAssetsDelete={() => refreshAlbum()}
/>
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload, Close, ...Object.values(Actions)]} /> <CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload, Close, ...Object.values(Actions)]} />
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: Route.albums() }}> <div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: Route.albums() }}>
@@ -357,12 +307,7 @@
{#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL} {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL}
<!-- ALBUM TITLE --> <!-- ALBUM TITLE -->
<section class="pt-8 md:pt-24"> <section class="pt-8 md:pt-24">
<AlbumTitle <AlbumTitle id={album.id} albumName={album.albumName} {isEditor} />
id={album.id}
albumName={album.albumName}
{isEditor}
onUpdate={(albumName) => (album = { ...album, albumName })}
/>
{#if album.assetCount > 0} {#if album.assetCount > 0}
<AlbumSummary {album} /> <AlbumSummary {album} />
@@ -406,11 +351,7 @@
<ActionButton action={Actions.Share} /> <ActionButton action={Actions.Share} />
</div> </div>
{/if} {/if}
<AlbumDescription <AlbumDescription id={album.id} {isEditor} description={album.description} />
id={album.id}
{isEditor}
bind:description={() => album.description, (description) => (album = { ...album, description })}
/>
</section> </section>
{/if} {/if}

Some files were not shown because too many files have changed in this diff Show More