Author SHA1 Message Date
Codex 621504fcda Prepare local11 release and cover concurrent JPEG XL color transforms 2026-10-26 22:38:18 +03:00
YarosandCodex 919a4fd9ef fix: watch asset delete/archive in search (#31343)
* fix: watch asset delete/archive in search

* fix: restore via toast

* chore: format

* refactor: simplify search asset pruning

* chore: implement suggestions

* chore: more suggestions

* fix: mounted check

(cherry picked from commit f7b8ae5da370f947223b006618e5b4e5fb42fd33)
2026-10-26 22:35:01 +03:00
MertandCodex 2ed9207c3f perf(mobile): cache srgb transform (#32730)
(cherry picked from commit 18f1a44d1b934fc9d98ddb2be6918b7617c9c896)
2026-10-26 22:34:24 +03:00
Mees FrenselandCodex c48394aa6e refactor(web): use declaration tags instead of @const (#32639)
(cherry picked from commit c6d1e15c77c5f3786b26b292def27247b258cde5)
2026-10-26 22:34:24 +03:00
renovate[bot]andCodex 86c8b9e04d chore(deps): update machine-learning (#32125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 256eb55dbe92ac77e02794442f9487e88ffed2b1)
2026-10-25 16:24:17 +03:00
renovate[bot]andCodex 1190d0cc80 fix(deps): update dependency pillow to v13 [security] (#32621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit ea7080bd755f7e879e0753dea59fabde331667b7)
2026-10-25 16:23:45 +03:00
bo0tzzandCodex 9669f7cc6e perf(server): use a createdAt range for "recently added" time buckets (#32434)
(cherry picked from commit cf719e733e6250e0fcd8f45d2e2c46f166d3557d)
2026-10-25 16:23:45 +03:00
Codex a5b2dcb3c2 Define the JPEG XL decode bound for the local Android image pipeline 2026-10-24 12:21:17 +03:00
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
Codex d17640a283 Handle optional OAuth config in visibility regression assertion 2026-10-22 21:15:41 +03:00
Codex 9e889bd4dc Adapt local regression tests to new configuration queries 2026-10-22 20:48:48 +03:00
Codex ca4d8d5a7a Prepare 3.3.5-local.8 and complete deletion regression test imports 2026-10-22 20:44:45 +03:00
Jason RasmussenandCodex 5f54700344 refactor: folder view queries (#32667)
(cherry picked from commit 6bf016964515ed65bb45cd1cf7b7bfadb405559a)
2026-10-22 20:43:53 +03:00
Jason RasmussenandCodex 0991f506bb refactor: shared link queries (#32666)
(cherry picked from commit 0a73481a8fdb3cfbab29f23da48e26133dc4c064)
2026-10-22 20:43:53 +03:00
Jason RasmussenandCodex fa77f168a0 refactor: notification queries (#32653)
(cherry picked from commit 8f3ba2c808e02e2a606b6acc65ce22f8ee3f9b34)
2026-10-22 20:43:53 +03:00
gitTurbofliegerandCodex 3aa65809b1 fix(mobile): stop trashing assets after deletion cancellation (#32522)
* fix(mobile): stop trashing assets after deletion cancellation

* fix(mobile): handle Android system delete cancellation

* fix(mobile): simplify

* simplify test

(cherry picked from commit 3517973b8a316c8fb729bf82fe4bbf95884b9146)
2026-10-22 20:43:53 +03:00
Jason RasmussenandCodex 9a66f16102 fix: account management url visibility (#32645)
(cherry picked from commit 22fe7b4179464c73d7e0487df90f2064fa30a5e0)
2026-10-22 20:43:14 +03:00
Jason RasmussenandCodex 493870ad7b refactor: config queries (#32498)
(cherry picked from commit b45ec0f55abb9dc39fbbb39dcf5296d45a726ba0)
2026-10-22 20:43:14 +03:00
Jason RasmussenandCodex 1e20bdd74e refactor: workflow queries (#32654)
(cherry picked from commit 98680ad6993ed917e630f8f4d1dbd1fbb6c41936)
2026-10-22 20:43:14 +03:00
Santo ShakilandCodex 99a7830f6c fix(mobile): bump sqlite3_connection_pool to 0.2.13 (#32662)
* fix(mobile): bump sqlite3_connection_pool to 0.2.13

* fix(mobile): require sqlite3_connection_pool 0.2.13

(cherry picked from commit 539499ee5e3843d338287edcf6808c1820c74800)
2026-10-22 20:43:14 +03:00
271 changed files with 6059 additions and 2785 deletions
+22
View File
@@ -1,5 +1,6 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'src/../playwright.config.js';
import { adminConfig, publicConfig, userConfig } from 'src/ui/mock-network/config-fixtures.js';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
@@ -113,6 +114,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
},
});
});
await context.route('**/api/public/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: publicConfig,
});
});
await context.route('**/api/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: userConfig,
});
});
await context.route('**/api/admin/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: adminConfig,
});
});
await context.route('**/api/server/features', async (route) => {
return route.fulfill({
status: 200,
+171
View File
@@ -0,0 +1,171 @@
export const publicConfig = {
ffmpeg: { realtime: { enabled: false } },
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false },
passkey: { enabled: true },
passwordLogin: { enabled: true },
server: { loginPageMessage: '' },
};
export const userConfig = {
ffmpeg: { realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] } },
machineLearning: {
enabled: false,
clip: { enabled: true },
duplicateDetection: { enabled: true },
facialRecognition: { enabled: true, minFaces: 3 },
ocr: { enabled: true },
},
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: { enabled: true },
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false, accountManagementUrl: '' },
passkey: { enabled: true },
passwordLogin: { enabled: true },
image: { thumbnail: { size: 250 }, preview: { size: 1440 }, fullsize: { enabled: false } },
trash: { enabled: true, days: 30 },
notifications: { smtp: { enabled: false } },
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
user: { deleteDelay: 7 },
};
export const adminConfig = {
backup: { database: { enabled: true, cronExpression: '0 02 * * *', keepLastAmount: 14 } },
ffmpeg: {
crf: 23,
threads: 0,
preset: 'ultrafast',
targetVideoCodec: 'h264',
acceptedVideoCodecs: ['h264'],
targetAudioCodec: 'aac',
acceptedAudioCodecs: ['aac', 'mp3', 'opus'],
acceptedContainers: ['mov', 'ogg', 'webm'],
targetResolution: '720',
maxBitrate: '0',
bframes: -1,
refs: 0,
gopSize: 0,
temporalAQ: false,
cqMode: 'auto',
twoPass: false,
preferredHwDevice: 'auto',
transcode: 'required',
tonemap: 'hable',
accel: 'disabled',
accelDecode: true,
realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] },
},
integrityChecks: {
missingFiles: { enabled: true, cronExpression: '0 03 * * *' },
untrackedFiles: { enabled: true, cronExpression: '0 03 * * *' },
checksumFiles: { enabled: true, cronExpression: '0 03 * * *', timeLimit: 3_600_000, percentageLimit: 1 },
},
job: {
thumbnailGeneration: { concurrency: 3 },
metadataExtraction: { concurrency: 5 },
videoConversion: { concurrency: 1 },
faceDetection: { concurrency: 2 },
smartSearch: { concurrency: 2 },
backgroundTask: { concurrency: 5 },
migration: { concurrency: 5 },
search: { concurrency: 5 },
sidecar: { concurrency: 5 },
library: { concurrency: 5 },
notifications: { concurrency: 5 },
ocr: { concurrency: 1 },
workflow: { concurrency: 5 },
editor: { concurrency: 2 },
integrityCheck: { concurrency: 1 },
},
logging: { enabled: true, level: 'log' },
machineLearning: {
enabled: false,
urls: ['http://immich-machine-learning:3003'],
availabilityChecks: { enabled: true, timeout: 2000, interval: 30_000 },
clip: { enabled: true, modelName: 'ViT-B-32__openai' },
duplicateDetection: { enabled: true, maxDistance: 0.01 },
facialRecognition: { enabled: true, modelName: 'buffalo_l', minScore: 0.7, maxDistance: 0.5, minFaces: 3 },
ocr: {
enabled: true,
modelName: 'PP-OCRv5_mobile',
minDetectionScore: 0.5,
minRecognitionScore: 0.8,
maxResolution: 736,
},
},
map: {
enabled: true,
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: { enabled: true },
metadata: { faces: { import: false } },
oauth: {
autoLaunch: false,
autoRegister: true,
buttonText: 'Login with OAuth',
clientId: '',
clientSecret: '',
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
accountManagementUrl: '',
endSessionEndpoint: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',
storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
roleClaim: 'immich_role',
tokenEndpointAuthMethod: 'client_secret_post',
timeout: 30_000,
allowInsecureRequests: false,
},
passkey: { enabled: true, domain: null, additionalDomains: [] },
passwordLogin: { enabled: true },
storageTemplate: {
enabled: false,
hashVerificationEnabled: true,
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
image: {
thumbnail: { format: 'webp', size: 250, quality: 80, progressive: false },
preview: { format: 'jpeg', size: 1440, quality: 80, progressive: false },
colorspace: 'p3',
extractEmbedded: false,
fullsize: { enabled: false, format: 'jpeg', quality: 80, progressive: false },
},
newVersionCheck: { enabled: true, channel: 'stable' },
nightlyTasks: {
startTime: '00:00',
databaseCleanup: true,
generateMemories: true,
syncQuotaUsage: true,
missingThumbnails: true,
clusterNewFaces: true,
},
trash: { enabled: true, days: 30 },
theme: { customCss: '' },
library: { scan: { enabled: true, cronExpression: '0 0 * * *' }, watch: { enabled: false } },
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
notifications: {
smtp: {
enabled: false,
from: '',
replyTo: '',
transport: { ignoreCert: false, host: '', port: 587, secure: false, username: '', password: '' },
},
},
templates: { email: { welcomeTemplate: '', albumInviteTemplate: '', albumUpdateTemplate: '' } },
user: { deleteDelay: 7 },
};
+2 -2
View File
@@ -14,7 +14,7 @@ dependencies = [
"onnx>=1.22.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=12.2,<13",
"pillow>=13,<14",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",
@@ -64,7 +64,7 @@ compile-bytecode = true
override-dependencies = ["opencv-python ; sys_platform == 'never'"] # only install the headless version
[tool.uv.sources]
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "8bf3fd1b44b0c8608f52fb6e50ddff52826576d8" }
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "a1eeff2c2118d76067efecf4cc4630998eac519a" }
onnxruntime-gpu = { index = "onnxruntime-cuda-12" }
openvino = { index = "openvino-nightly" }
+719 -531
View File
File diff suppressed because it is too large Load Diff
-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'
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
flutter {
@@ -1,52 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <string.h>
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_allocate(
JNIEnv *env, jclass clazz, jint size) {
void *ptr = malloc(size);
return (jlong) ptr;
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_NativeBuffer_free(
JNIEnv *env, jclass clazz, jlong address) {
free((void *) address);
}
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_realloc(
JNIEnv *env, jclass clazz, jlong address, jint size) {
void *ptr = realloc((void *) address, size);
return (jlong) ptr;
}
JNIEXPORT jobject JNICALL
Java_app_alextran_immich_NativeBuffer_wrap(
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_NativeBuffer_copy(
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
void *src = (*env)->GetDirectBufferAddress(env, buffer);
if (src != NULL) {
memcpy((void *) destAddress, (char *) src + offset, length);
}
}
/**
* Creates a JNI global reference to the given object and returns its address.
* The caller is responsible for deleting the global reference when it's no longer needed.
*/
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
if (obj == NULL) {
return 0;
}
jobject globalRef = (*env)->NewGlobalRef(env, obj);
return (jlong) globalRef;
}
@@ -1,173 +0,0 @@
#include <jni.h>
#include <stdlib.h>
#include <stdint.h>
#include <android/bitmap.h>
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
#define TILE 32
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
enum {
ORIENTATION_FLIP_HORIZONTAL = 2,
ORIENTATION_ROTATE_180 = 3,
ORIENTATION_FLIP_VERTICAL = 4,
ORIENTATION_TRANSPOSE = 5,
ORIENTATION_ROTATE_90 = 6,
ORIENTATION_TRANSVERSE = 7,
ORIENTATION_ROTATE_270 = 8,
};
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
static int swaps_dims(int o) {
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
}
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
switch (o) {
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
default: *base = 0; *stepX = 1; *stepY = dw; break;
}
}
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
for (int ty = 0; ty < sh; ty += TILE) {
int yEnd = ty + TILE < sh ? ty + TILE : sh;
for (int tx = 0; tx < sw; tx += TILE) {
int xEnd = tx + TILE < sw ? tx + TILE : sw;
for (int sy = ty; sy < yEnd; sy++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
for (int sx = tx; sx < xEnd; sx++) {
dst[idx] = srcRow[sx];
idx += stepX;
}
}
}
}
}
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_rotate(
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
return 0;
}
int sw = (int) info.width;
int sh = (int) info.height;
int dw = swaps_dims(orientation) ? sh : sw;
int dh = swaps_dims(orientation) ? sw : sh;
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
int64_t base, stepX, stepY;
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {dw, dh, dw * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
for (int y = 0; y < h; y++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
uint32_t *dstRow = dst + (size_t) y * w;
for (int x = 0; x < w; x++) {
uint32_t px = srcRow[x];
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
uint32_t a = ((px >> 30) & 0x3) * 85u;
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
}
}
}
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_convert1010102(
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
return 0;
}
int w = (int) info.width;
int h = (int) info.height;
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {w, h, w * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}
@@ -6,7 +6,7 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024
object NativeBuffer {
init {
System.loadLibrary("native_buffer")
System.loadLibrary("native_core_ffi")
}
@JvmStatic
@@ -1,11 +1,11 @@
package app.alextran.immich
import android.graphics.Bitmap
import java.nio.ByteBuffer
object NativeImage {
init {
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
System.loadLibrary("native_buffer")
System.loadLibrary("native_core_ffi")
}
/**
@@ -26,4 +26,24 @@ object NativeImage {
*/
@JvmStatic
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.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.NativeImage
import app.alextran.immich.core.HttpClientManager
import kotlinx.coroutines.*
import okhttp3.Cache
@@ -29,6 +30,8 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
// Match the upstream GPU texture bound without depending on a newer local image decoder.
private const val MAX_JXL_PIXEL_SIZE = 16384
private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -56,10 +59,9 @@ private val RAW_MIME_TYPES = setOf(
"image/x-sony-sr2", "image/x-sony-srf", "image/x3f",
)
private fun isRawMime(contentType: String?): Boolean {
val mime = contentType?.substringBefore(';')?.trim()?.lowercase() ?: return false
return mime in RAW_MIME_TYPES
}
private fun mimeType(contentType: String?) = contentType?.substringBefore(';')?.trim()?.lowercase()
private fun isRawMime(contentType: String?) = mimeType(contentType) in RAW_MIME_TYPES
class RemoteImagesImpl(context: Context) : RemoteImageApi {
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
@@ -97,6 +99,42 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
return@fetch callback(CANCELLED)
}
// Neither Android nor Flutter can decode JPEG XL, so this applies to encoded requests too.
if (mimeType(contentType) == "image/jxl") {
decodeExecutor.execute {
val info = IntArray(3)
// The fetch is done, so cancelling now stops the decode instead.
val cancelled = ByteBuffer.allocateDirect(1)
signal.setOnCancelListener { NativeImage.cancel(cancelled) }
val pointer = if (signal.isCanceled) 0L else NativeImage.decodeJxl(
buffer.pointer,
buffer.offset,
width?.toInt() ?: 0,
height?.toInt() ?: 0,
MAX_JXL_PIXEL_SIZE,
cancelled,
info
)
buffer.free()
requestMap.remove(requestId)
callback(
when {
pointer != 0L -> Result.success(
mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
)
signal.isCanceled -> CANCELLED
else -> Result.failure(IOException("Failed to decode JPEG XL image"))
}
)
}
return@fetch
}
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
// 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
@@ -272,7 +310,11 @@ private class CronetImageFetcher : ImageFetcher {
requestBuilder.addHeader(key, value)
}
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()
}
@@ -328,12 +370,16 @@ private class CronetImageFetcher : ImageFetcher {
) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null
private var error: Exception? = null
@Volatile var receivingResponse = false
@Volatile var requestCancel = false
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
request.followRedirect()
}
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
receivingResponse = true
if (requestCancel) return request.cancel()
if (info.httpStatusCode !in 200..299) {
error = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
return request.cancel()
@@ -361,6 +407,7 @@ private class CronetImageFetcher : ImageFetcher {
info: UrlResponseInfo,
byteBuffer: ByteBuffer
) {
if (requestCancel) return request.cancel()
try {
// Always pass a fresh wrap so byteBuffer.position() represents only the
// 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
}()
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
return requestOptions
}()
private static let maxPixelSize: CGFloat = 16384
private static let registry = RequestRegistry<ImageRequest>()
@@ -111,7 +110,7 @@ class LocalImageApiImpl: LocalImageApi {
let isOriginal = !(width > 0 && height > 0)
let targetSize = isOriginal
? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize)
? CGSize(width: ImageProcessing.maxPixelSize, height: ImageProcessing.maxPixelSize)
: CGSize(width: Double(width), height: Double(height))
let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill
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.
if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) {
if max(cgImage.width, cgImage.height) > ImageProcessing.maxPixelSize {
let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions
exactOptions.resizeMode = .exact
image = nil
@@ -7,15 +7,23 @@ import Photos
final class RemoteImageRequest: ImageRequest {
var task: URLSessionDataTask?
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) {
self.id = id
super.init(completion: completion)
decodeCancelled.initialize(to: false)
}
deinit {
decodeCancelled.deallocate()
}
override func cancel() {
super.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)))
}
// 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 {
let length = data.count
let pointer = malloc(length)!
@@ -1 +1,7 @@
#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;
+1 -1
View File
@@ -122,7 +122,7 @@ class Drift extends $Drift {
@override
Future<void> close() async {
await super.close();
_updatePool?.close();
await _updatePool?.close();
}
Future<void> reset() async {
@@ -79,8 +79,7 @@ abstract class ImageRequest {
return (codec, descriptor);
}
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async {
final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize);
Future<ui.FrameInfo?> _firstFrame((ui.Codec, ui.ImageDescriptor)? result) async {
if (result == null) {
return null;
}
@@ -116,7 +115,12 @@ abstract class ImageRequest {
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);
if (_isCancelled) {
malloc.free(pointer);
@@ -152,14 +156,6 @@ abstract class ImageRequest {
return null;
}
final frame = await codec.getNextFrame();
descriptor.dispose();
codec.dispose();
if (_isCancelled) {
frame.image.dispose();
return null;
}
return frame;
return (codec, descriptor);
}
}
@@ -28,7 +28,9 @@ class LocalImageRequest extends ImageRequest {
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);
}
@@ -21,22 +21,7 @@ class RemoteImageRequest extends ImageRequest {
width: decodeSize?.width.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 = 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,
};
final frame = await _firstFrame(await _codecFromResponse(info, decodeSize: decodeSize));
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@@ -53,14 +38,29 @@ class RemoteImageRequest extends ImageRequest {
width: null,
height: null,
);
if (info == null) {
return null;
}
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
return codec;
final result = await _codecFromResponse(info);
return result?.$1;
}
// 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
Future<void> _onCancelled() {
return remoteImageApi.cancelRequest(requestId);
@@ -12,7 +12,9 @@ class ThumbhashImageRequest extends ImageRequest {
}
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);
}
@@ -41,6 +41,23 @@ class RemoteAssetRepository extends DatabaseAccessor<Drift> with $RemoteAssetRep
return _assetSelectable(id).watchSingleOrNull();
}
Stream<Set<String>> watchDeletedAssetIds(Iterable<String> ids, AssetVisibility visibility) {
final idList = ids.toList(growable: false);
if (idList.isEmpty) {
return Stream.value(const <String>{});
}
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id])
..where(
_db.remoteAssetEntity.id.isIn(idList) &
(_db.remoteAssetEntity.deletedAt.isNotNull() |
_db.remoteAssetEntity.visibility.equalsValue(visibility).not()),
);
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).watch().map((rows) => rows.toSet());
}
Future<RemoteAsset?> get(String id) {
return _assetSelectable(id).getSingleOrNull();
}
@@ -121,14 +121,14 @@ class DeleteAction extends AssetActionBuilder {
required bool isLocalOnly,
}) async {
final assetService = ref.read(assetServiceProvider);
final message = context.t.trash_action_prompt(count: remoteIds.length);
if (localIds.isNotEmpty) {
await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
if (!context.mounted) {
final count = await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
if (count <= 0) {
return null;
}
}
final message = context.t.trash_action_prompt(count: remoteIds.length);
await assetService.trash(remoteIds);
return message;
}
@@ -3,7 +3,9 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
final searchPreFilterProvider = NotifierProvider<SearchFilterProvider, SearchFilter?>(SearchFilterProvider.new);
@@ -32,16 +34,21 @@ class SearchState {
}
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider), ref.watch(driftProvider).remoteAssetRepository),
);
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final SearchService _searchService;
final RemoteAssetRepository _remoteAssetRepository;
final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
StreamSubscription<Set<String>>? _deletedIdsSubscription;
List<BaseAsset> _results = const [];
int? _nextPage = 1;
Stream<int> get assetCount => _assetCountController.stream;
PaginatedSearchNotifier(this._searchService, this._remoteAssetRepository) : super(const SearchState());
Stream<int> get assetCount => _assetCountController.stream.distinct();
Future<void> search(SearchFilter filter) async {
if (state.nextPage == null || state.isLoading) {
@@ -52,24 +59,47 @@ class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final result = await _searchService.search(filter, state.nextPage!);
if (!mounted) {
return;
}
if (result == null) {
state = SearchState(assets: state.assets, nextPage: state.nextPage);
return;
}
final assets = [...state.assets, ...result.assets];
state = SearchState(assets: assets, nextPage: result.nextPage);
_assetCountController.add(assets.length);
_results = [..._results, ...result.assets];
_nextPage = result.nextPage;
_watchDeletedIds(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline);
}
void clear() {
state = const SearchState();
_assetCountController.add(0);
unawaited(_deletedIdsSubscription?.cancel());
_deletedIdsSubscription = null;
_results = const [];
_nextPage = 1;
_emit(const {});
}
void _watchDeletedIds(AssetVisibility visibility) {
unawaited(_deletedIdsSubscription?.cancel());
final ids = _results.whereType<RemoteAsset>().map((asset) => asset.id);
_deletedIdsSubscription = _remoteAssetRepository.watchDeletedAssetIds(ids, visibility).listen(_emit);
}
void _emit(Set<String> deletedIds) {
final visible = deletedIds.isEmpty
? _results
: _results.where((asset) => asset is! RemoteAsset || !deletedIds.contains(asset.id)).toList(growable: false);
state = SearchState(assets: visible, nextPage: _nextPage);
_assetCountController.add(visible.length);
}
@override
void dispose() {
unawaited(_deletedIdsSubscription?.cancel());
unawaited(_assetCountController.close());
super.dispose();
}
+7
View File
@@ -1,5 +1,6 @@
[tools]
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"]
version = "3.47.2"
@@ -34,6 +35,7 @@ depends = [
"codegen:dart",
"codegen:drift:migration",
"codegen:drift:schema",
"codegen:native",
"codegen:pigeon",
"codegen:translation",
]
@@ -80,6 +82,11 @@ alias = "watch"
description = "Watch and auto-generate dart code"
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"]
alias = "pigeon"
description = "Generate pigeon platform code"
+29 -6
View File
@@ -796,10 +796,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.2.0"
hooks_riverpod:
dependency: "direct main"
description:
@@ -1132,6 +1132,13 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -1140,6 +1147,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -1458,10 +1473,10 @@ packages:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
version: "1.1.1"
riverpod:
dependency: transitive
description:
@@ -1664,10 +1679,10 @@ packages:
dependency: "direct main"
description:
name: sqlite3_connection_pool
sha256: ad51972d37bdf82929888e4c85e722bd9a0b5ff8ad389f89b1b00d19603c0d2d
sha256: ad8413d9100598ee9ef1a27628f5673b7d9212fb9d15b2742f7fde690f743832
url: "https://pub.dev"
source: hosted
version: "0.2.7"
version: "0.2.13"
sqlite3_web:
dependency: transitive
description:
@@ -1772,6 +1787,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.4"
toml:
dependency: transitive
description:
name: toml
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
url: "https://pub.dev"
source: hosted
version: "0.18.0"
typed_data:
dependency: transitive
description:
+4 -2
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.3.5-local.7+3030500
version: 3.3.5-local.11+3030503
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -39,6 +39,8 @@ dependencies:
hooks_riverpod: ^2.6.1
http: ^1.6.0
image_picker: ^1.2.3
native_core:
path: ../native/native_core
immich_ui:
path: './packages/ui'
intl: ^0.20.2
@@ -69,7 +71,7 @@ dependencies:
stream_transform: ^2.1.1
sqlite3: ^3.4.0
sqlite_async: 0.14.2
sqlite3_connection_pool: ^0.2.7
sqlite3_connection_pool: ^0.2.13
thumbhash: 0.1.0+1
timezone: ^0.9.4
url_launcher: ^6.3.2
@@ -57,7 +57,7 @@ Future<void> _writerTask(String path) async {
} finally {
await db.close();
await dbConnection.close();
sqlitePool.close();
await sqlitePool.close();
}
}
@@ -76,6 +76,24 @@ void main() {
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 {
final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size(963, 642));
@@ -1,4 +1,6 @@
import 'package:async/async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import '../repository_context.dart';
@@ -64,4 +66,53 @@ void main() {
expect(result[0].id, remote.id);
});
});
group('watchDeletedAssetIds', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
await ctx.newAuthUser(id: userId);
});
test('hides trashed and archived assets but keeps matching and unsynced ones', () async {
final timeline = await ctx.newRemoteAsset(ownerId: userId);
final trashed = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2020));
final archived = await ctx.newRemoteAsset(ownerId: userId, visibility: AssetVisibility.archive);
final result = await sut.watchDeletedAssetIds([
timeline.id,
trashed.id,
archived.id,
'never-synced',
], AssetVisibility.timeline).first;
expect(result, {trashed.id, archived.id});
});
test('hides assets not matching the requested visibility for archive searches', () async {
final timeline = await ctx.newRemoteAsset(ownerId: userId);
final archived = await ctx.newRemoteAsset(ownerId: userId, visibility: AssetVisibility.archive);
final result = await sut.watchDeletedAssetIds([timeline.id, archived.id], AssetVisibility.archive).first;
expect(result, {timeline.id});
});
test('emits when an asset is trashed and again when it is restored', () async {
final asset = await ctx.newRemoteAsset(ownerId: userId);
final queue = StreamQueue(sut.watchDeletedAssetIds([asset.id], AssetVisibility.timeline));
expect(await queue.next, isEmpty);
await sut.trash([asset.id]);
expect(await queue.next, {asset.id});
await sut.restoreTrash([asset.id]);
expect(await queue.next, isEmpty);
await queue.cancel();
});
});
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -97,6 +99,7 @@ void main() {
});
testWidgets('trashes a merged asset and removes its device copy', (tester) async {
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) async => 1);
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset});
@@ -106,6 +109,65 @@ void main() {
verify(() => assetService.trash([asset.id])).called(1);
});
testWidgets('does not trash a merged asset when the MANAGE_MEDIA prompt is cancelled', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset});
await respondToDialog(tester, confirm: false);
verifyNever(() => cleanupService.deleteLocalAssets(any()));
verifyNever(() => assetService.trash(any()));
verifyNever(() => assetService.delete(any()));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('does not trash a merged asset when the Android system prompt is cancelled', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 30);
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) async => 0);
final asset = owned(localId: 'local');
await pumpDelete(tester, {asset});
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
verifyNever(() => assetService.trash(any()));
verifyNever(() => assetService.delete(any()));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('finishes trashing when the action context is disposed during local cleanup', (tester) async {
final asset = owned(localId: 'local');
final cleanupResult = Completer<int>();
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) => cleanupResult.future);
var showAction = true;
late StateSetter updateHost;
await tester.pumpTestWidget(
context,
StatefulBuilder(
builder: (_, setState) {
updateHost = setState;
return showAction
? const ActionIconButton(action: DeleteAction(source: .timeline))
: const SizedBox.shrink();
},
),
overrides: context.selected({asset}),
);
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
updateHost(() => showAction = false);
await tester.pump();
cleanupResult.complete(1);
await tester.pumpAndSettle();
verify(() => assetService.trash([asset.id])).called(1);
});
testWidgets('offers an undo that restores the trashed assets', (tester) async {
final asset = owned();
@@ -283,6 +345,7 @@ void main() {
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 28);
final backedUp = owned(localId: 'local');
final localOnly = LocalAssetFactory.create();
when(() => cleanupService.deleteLocalAssets(['local', localOnly.id])).thenAnswer((_) async => 1);
await pumpDelete(tester, {backedUp, localOnly});
await tester.pump(const Duration(milliseconds: 300));
+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);
}
}
+256
View File
@@ -0,0 +1,256 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock};
use jxl::api::{
AlphaMode, Endianness, Event, ExtraChannel, JxlColorEncoding, JxlColorProfile, JxlColorType,
JxlDataFormat, JxlDecoder, JxlDecoderOptions, JxlOutputBuffer, JxlParallelRunner,
JxlParallelRunnerFun, JxlPixelFormat, JxlPrimaries, JxlTransferFunction, JxlWhitePoint,
};
use moxcms::{Layout, MatrixCoefficients, Transform16BitExecutor};
use quick_cache::sync::Cache;
use rayon::prelude::*;
use crate::color::{self, CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
use crate::image::{Image, Pixels, Samples};
use crate::resize;
/// A JPEG XL image whose header has been read.
pub struct Jxl<'a> {
decoder: JxlDecoder,
input: &'a [u8],
/// The space of the output.
cicp: CicpProfile,
/// Converts wide samples to sRGB when the output is not in the source space.
transform: Option<Arc<Transform16BitExecutor>>,
}
/// Runs on the global pool with up to this many threads, or inline with one.
struct Rayon(usize);
impl JxlParallelRunner for Rayon {
fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> {
(0..num).into_par_iter().try_for_each(fun)
}
fn num_threads(&self) -> usize {
self.0
}
}
/// Stops handing out work once the flag is set.
struct Cancellable<'a> {
pool: Rayon,
cancelled: &'a AtomicBool,
}
impl JxlParallelRunner for Cancellable<'_> {
fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> {
self.pool.run(num, fun)
}
fn num_threads(&self) -> usize {
self.pool.num_threads()
}
// jxl-rs only calls this, and each of its tasks loops many items, so check cancellation here.
fn run_ordered(
&mut self,
num: usize,
max_threads: Option<usize>,
fun: &JxlParallelRunnerFun<'_>,
) -> jxl::error::Result<()> {
self.pool.run_ordered(num, max_threads, &|i| {
if self.cancelled.load(Ordering::Relaxed) {
// Any error stops the decode.
return Err(jxl::error::Error::ApiUsageError("cancelled"));
}
fun(i)
})
}
}
impl<'a> Jxl<'a> {
pub fn open(mut input: &'a [u8], target: Target) -> Option<Self> {
let mut options = JxlDecoderOptions::default();
// Colour transforms need straight alpha; premultiplying happens last.
options.alpha_mode = AlphaMode::Unpremultiplied;
let mut decoder = JxlDecoder::new(options);
let Event::BasicInfo = decoder.process(&mut input, None, None).ok()? else {
return None;
};
let extra = decoder.basic_info()?.extra_channels.len();
// An integer pixel format ensures the output profile below is sRGB rather than float scRGB.
decoder
.set_pixel_format(JxlPixelFormat::rgba8(extra))
.ok()?;
let profile = decoder.output_color_profile()?;
if let Some(cicp) = cicp(profile)
&& (target == Target::Source || color::is_srgb(cicp))
{
if color::is_hdr(cicp) {
decoder
.set_pixel_format(JxlPixelFormat::rgba16(extra))
.ok()?;
}
return Some(Self {
decoder,
input,
cicp,
transform: None,
});
}
let (transform, layout) = srgb_transform(profile)?;
decoder
.set_pixel_format(JxlPixelFormat {
color_type: match layout {
Layout::GrayAlpha => JxlColorType::GrayscaleAlpha,
_ => JxlColorType::Rgba,
},
color_data_format: Some(JxlDataFormat::U16 {
endianness: Endianness::native(),
bit_depth: 12,
}),
extra_channel_format: vec![None; extra],
})
.ok()?;
Some(Self {
decoder,
input,
cicp: color::SRGB,
transform: Some(transform),
})
}
/// Decodes the first frame unless cancelled, downscaled to cover `target` pixels and to fit
/// within `max_pixel_size`.
pub fn decode(
mut self,
target: Option<(usize, usize)>,
max_pixel_size: usize,
cancelled: &AtomicBool,
) -> Option<Image> {
let info = self.decoder.basic_info()?;
let (w, h) = info.size;
let alpha = info
.extra_channels
.iter()
.any(|ec| ec.ec_type == ExtraChannel::Alpha);
let format = self.decoder.current_pixel_format()?;
let pixel_bytes =
format.color_type.samples_per_pixel() * format.color_data_format?.bytes_per_sample();
let stride = w.checked_mul(pixel_bytes)?;
let size = resize::cover((w, h), target, max_pixel_size);
let samples = if color::is_hdr(self.cicp) {
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
self.decode_frame(&mut rgba, stride, cancelled)?;
Samples::U16(rgba.premultiply_resize(alpha, size)?)
} else {
// The buffer is sized for the wide decode and ends up holding the RGBA8 result at its
// front.
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
self.decode_frame(&mut rgba, stride, cancelled)?;
if let Some(transform) = &self.transform {
color::convert_bands(transform.as_ref(), &mut rgba, stride, cancelled)?;
}
Samples::U8(rgba.premultiply_resize(alpha, size)?)
};
Some(Image {
cicp: self.cicp,
samples,
})
}
fn decode_frame<T>(
&mut self,
rgba: &mut Pixels<T>,
stride: usize,
cancelled: &AtomicBool,
) -> Option<()> {
let (w, h) = (rgba.width(), rgba.height());
let buf = rgba.bytes_mut();
// The pool only pays off for large images, since thumbs are already decoded in parallel.
let threads = if w * h > 512 * 512 {
rayon::current_num_threads()
} else {
1
};
let mut runner = Cancellable {
pool: Rayon(threads),
cancelled,
};
loop {
let output = &mut [JxlOutputBuffer::new(buf, h, stride)];
match self
.decoder
.process(&mut self.input, Some(output), Some(&mut runner))
.ok()?
{
Event::FrameHeader => {}
// Resizing costs ~10% of a thumbnail decode, wasted on tiles that scrolled away
// meanwhile.
Event::FrameComplete { .. } => {
return (!cancelled.load(Ordering::Relaxed)).then_some(());
}
_ => return None,
}
}
}
}
/// The space of the decoded samples, if CICP can describe it.
fn cicp(profile: &JxlColorProfile) -> Option<CicpProfile> {
let JxlColorProfile::Simple(encoding) = profile else {
return None;
};
let (primaries, transfer_function) = match encoding {
JxlColorEncoding::RgbColorSpace {
white_point: JxlWhitePoint::D65,
primaries,
transfer_function,
..
} => (primaries, transfer_function),
// Gray samples decode to neutral RGB, which any primaries reproduce.
JxlColorEncoding::GrayscaleColorSpace {
white_point: JxlWhitePoint::D65,
transfer_function,
..
} => (&JxlPrimaries::SRGB, transfer_function),
_ => return None,
};
Some(CicpProfile {
color_primaries: match primaries {
JxlPrimaries::SRGB => CicpColorPrimaries::Bt709,
JxlPrimaries::BT2100 => CicpColorPrimaries::Bt2020,
JxlPrimaries::P3 => CicpColorPrimaries::Smpte432,
JxlPrimaries::Chromaticities { .. } => return None,
},
transfer_characteristics: match transfer_function {
JxlTransferFunction::SRGB => TransferCharacteristics::Srgb,
JxlTransferFunction::PQ => TransferCharacteristics::Smpte2084,
JxlTransferFunction::HLG => TransferCharacteristics::Hlg,
_ => return None,
},
matrix_coefficients: MatrixCoefficients::Identity,
full_range: true,
})
}
/// Reuse the transform to sRGB for a given source profile.
type Transforms<K> = Cache<K, (Arc<Transform16BitExecutor>, Layout)>;
/// [`color::to_srgb`] for `profile`, built once per distinct profile.
fn srgb_transform(profile: &JxlColorProfile) -> Option<(Arc<Transform16BitExecutor>, Layout)> {
const TRANSFORMS: usize = 8;
static ENCODINGS: LazyLock<Transforms<String>> = LazyLock::new(|| Cache::new(TRANSFORMS));
static ICCS: LazyLock<Transforms<Vec<u8>>> = LazyLock::new(|| Cache::new(TRANSFORMS));
match profile {
JxlColorProfile::Simple(encoding) => ENCODINGS
.get_or_insert_with(&encoding.get_color_encoding_description(), || {
color::to_srgb(&profile.try_as_icc().ok_or(())?).ok_or(())
}),
JxlColorProfile::Icc(icc) => {
ICCS.get_or_insert_with(icc.as_slice(), || color::to_srgb(icc).ok_or(()))
}
}
.ok()
}
+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.
+230
View File
@@ -0,0 +1,230 @@
use std::sync::atomic::AtomicBool;
use native_core::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
use native_core::image::{Image, Pixels, Samples};
use native_core::jxl::Jxl;
fn decode(data: &[u8], target: Target) -> Option<Image> {
Jxl::open(data, target)?.decode(None, 16384, &AtomicBool::new(false))
}
fn decode_srgb(data: &[u8]) -> Option<Pixels<u8>> {
let Image {
cicp:
CicpProfile {
color_primaries: CicpColorPrimaries::Bt709,
transfer_characteristics: TransferCharacteristics::Srgb,
..
},
samples: Samples::U8(rgba),
} = decode(data, Target::Srgb)?
else {
panic!("not decoded to sRGB");
};
Some(rgba)
}
#[test]
fn decodes_to_premultiplied_srgb() {
let cases: [(&str, &[u8], [u8; 8]); 5] = [
(
"sRGB with alpha",
include_bytes!("fixtures/srgb_alpha.jxl"),
[128, 0, 0, 128, 0, 0, 255, 255],
),
(
"Display P3",
include_bytes!("fixtures/p3.jxl"),
[255, 0, 0, 255, 255, 255, 255, 255],
),
(
"grayscale",
include_bytes!("fixtures/gray.jxl"),
[0, 0, 0, 255, 128, 128, 128, 255],
),
(
"grayscale with ICC profile",
include_bytes!("fixtures/gray_icc.jxl"),
[81, 81, 81, 255, 203, 203, 203, 255],
),
(
"animation",
include_bytes!("fixtures/animated.jxl"),
[255, 0, 0, 255, 255, 0, 0, 255],
),
];
for (name, data, expected) in cases {
let rgba = decode_srgb(data).expect(name);
assert_eq!(
(rgba.width(), rgba.height(), rgba.pixels().as_flattened()),
(2, 1, &expected[..]),
"{name}"
);
}
}
#[test]
fn converts_lossy_icc_to_srgb() {
// jxl-rs renders XYB straight to sRGB, while a JPEG transcode decodes in its ICC space and
// takes our transform.
for data in [
&include_bytes!("fixtures/xyb_icc.jxl")[..],
include_bytes!("fixtures/jpeg_icc.jxl"),
] {
let rgba = decode_srgb(data).unwrap();
for px in rgba.pixels() {
assert!(
px.iter()
.zip([210, 119, 65, 255])
.all(|(&a, b)| a.abs_diff(b) <= 3),
"{px:?}"
);
}
}
}
#[test]
fn concurrent_decodes_keep_profile_colors_when_transforms_are_reused() {
let cases: [(&[u8], [u8; 8]); 3] = [
(
include_bytes!("fixtures/p3.jxl"),
[255, 0, 0, 255, 255, 255, 255, 255],
),
(
include_bytes!("fixtures/gray_icc.jxl"),
[81, 81, 81, 255, 203, 203, 203, 255],
),
(
include_bytes!("fixtures/srgb_alpha.jxl"),
[128, 0, 0, 128, 0, 0, 255, 255],
),
];
std::thread::scope(|scope| {
for _ in 0..4 {
scope.spawn(|| {
for _ in 0..8 {
for (data, expected) in cases {
let rgba = decode_srgb(data).unwrap();
assert_eq!(rgba.pixels().as_flattened(), &expected);
}
}
});
}
});
}
#[test]
fn tone_maps_hdr() {
let rgba = decode_srgb(include_bytes!("fixtures/pq_grays.jxl")).unwrap();
let levels: Vec<u8> = rgba
.pixels()
.iter()
.map(|px| {
assert!(
px[..3].iter().max().unwrap() - px[..3].iter().min().unwrap() <= 4,
"{px:?} is not neutral"
);
px[1]
})
.collect();
assert!(
levels.is_sorted_by(|a, b| a < b) && levels[2] < 255,
"{levels:?}"
);
}
#[test]
fn keeps_spaces_that_cicp_describes() {
let pq = decode(include_bytes!("fixtures/pq_alpha.jxl"), Target::Source);
let Some(Image {
cicp:
CicpProfile {
color_primaries: CicpColorPrimaries::Bt2020,
transfer_characteristics: TransferCharacteristics::Smpte2084,
..
},
samples: Samples::U16(rgba),
}) = pq
else {
panic!("not decoded to BT.2100 PQ");
};
assert_eq!(
rgba.pixels(),
[[20000, 15000, 10000, 32768], [49271, 49271, 49271, 65535]]
);
let p3 = decode(include_bytes!("fixtures/p3.jxl"), Target::Source);
let Some(Image {
cicp:
CicpProfile {
color_primaries: CicpColorPrimaries::Smpte432,
transfer_characteristics: TransferCharacteristics::Srgb,
..
},
samples: Samples::U8(rgba),
}) = p3
else {
panic!("not decoded to Display P3");
};
assert_eq!(rgba.pixels(), [[255, 0, 0, 255], [255, 255, 255, 255]]);
}
#[test]
fn converts_other_spaces_to_srgb() {
let data = include_bytes!("fixtures/gray_icc.jxl");
let Some(Image {
cicp:
CicpProfile {
color_primaries: CicpColorPrimaries::Bt709,
transfer_characteristics: TransferCharacteristics::Srgb,
..
},
samples: Samples::U8(rgba),
}) = decode(data, Target::Source)
else {
panic!("not decoded to sRGB");
};
assert_eq!(rgba.pixels(), decode_srgb(data).unwrap().pixels());
}
#[test]
fn downscales_to_cover() {
let data = include_bytes!("fixtures/jpeg_icc.jxl");
let Some(Image {
samples: Samples::U8(rgba),
..
}) =
Jxl::open(data, Target::Srgb)
.unwrap()
.decode(Some((8, 4)), 16384, &AtomicBool::new(false))
else {
panic!("not decoded to 8 bits");
};
// 16x16 covers 8x4 at 8x8, read through the wide decode buffer.
assert_eq!((rgba.width(), rgba.height()), (8, 8));
for px in rgba.pixels() {
assert!(
px.iter()
.zip([210, 119, 65, 255])
.all(|(&a, b)| a.abs_diff(b) <= 3),
"{px:?}"
);
}
}
#[test]
fn rejects_truncated_input() {
let data = include_bytes!("fixtures/srgb_alpha.jxl");
assert!(decode_srgb(&data[..data.len() / 2]).is_none());
}
#[test]
fn stops_when_cancelled() {
let data = include_bytes!("fixtures/large.jxl");
let decode = |cancelled| {
Jxl::open(data, Target::Srgb)
.unwrap()
.decode(None, 16384, &AtomicBool::new(cancelled))
.is_some()
};
assert_eq!((decode(false), decode(true)), (true, false));
}
+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);
});
}
+5
View File
@@ -33974,6 +33974,11 @@
},
"UserConfigOAuthDto": {
"properties": {
"accountManagementUrl": {
"default": "",
"description": "Account management URL",
"type": "string"
},
"autoLaunch": {
"description": "Auto launch",
"type": "boolean"
+2
View File
@@ -1616,6 +1616,8 @@ export type UserConfigNotificationsDto = {
smtp: UserConfigSmtpDto;
};
export type UserConfigOAuthDto = {
/** Account management URL */
accountManagementUrl?: string;
/** Auto launch */
autoLaunch: boolean;
/** Button text */
+5 -5
View File
@@ -1177,8 +1177,8 @@ importers:
specifier: ^5.5.6
version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.9)
eslint-plugin-svelte:
specifier: ^3.12.4
version: 3.23.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1))
specifier: ^3.23.1
version: 3.23.1(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1))
eslint-plugin-unicorn:
specifier: ^75.0.0
version: 75.0.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))
@@ -7821,8 +7821,8 @@ packages:
eslint-config-prettier:
optional: true
eslint-plugin-svelte@3.23.0:
resolution: {integrity: sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==}
eslint-plugin-svelte@3.23.1:
resolution: {integrity: sha512-+w86Jlx4IRUuuvtqD6j4njql+akx+81JnpvbN5de3AV5iq2SexvST/Jr51CiTlKeY0i+vhoKjPO/ywFKIP75sA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.1 || ^9.0.0 || ^10.0.0
@@ -21185,7 +21185,7 @@ snapshots:
'@types/eslint': 9.6.1
eslint-config-prettier: 10.1.8(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))
eslint-plugin-svelte@3.23.0(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1)):
eslint-plugin-svelte@3.23.1(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.57.1(@typescript-eslint/types@8.70.1)):
dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))
'@jridgewell/sourcemap-codec': 1.6.0
+11
View File
@@ -39,6 +39,17 @@ describe('config visibility', () => {
expect(getKeysDeep(mapUserConfig(defaults))).toEqual(expect.arrayContaining(PUBLIC_PROPERTIES));
});
it('exposes the OAuth account link to users without exposing client secrets or the link publicly', () => {
const config = {
...defaults,
oauth: { ...defaults.oauth, accountManagementUrl: 'https://accounts.example.com', clientSecret: 'private' },
};
expect(mapUserConfig(config).oauth?.accountManagementUrl).toBe('https://accounts.example.com');
expect(mapUserConfig(config).oauth).not.toHaveProperty('clientSecret');
expect(mapPublicConfig(config).oauth).not.toHaveProperty('accountManagementUrl');
expect(mapPublicConfig(config).oauth).not.toHaveProperty('clientSecret');
});
it('should accept the defaults with the admin schema', () => {
expect(AdminConfigDto.schema.safeParse(defaults)).toEqual(expect.objectContaining({ success: true }));
});
+2 -1
View File
@@ -303,7 +303,8 @@ const AdminConfigSchemaWithVisibility = z
accountManagementUrl: emptyOrUrl('Account management URL must be an empty string or a valid URL')
.describe('Account management URL')
.optional()
.default(''),
.default('')
.meta({ visibility: User }),
scope: z.string().describe('Scope'),
prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'),
endSessionEndpoint: emptyOrUrl('endSessionEndpoint must be an empty string or a valid URL').describe(
+9 -1
View File
@@ -818,6 +818,7 @@ export class AssetRepository {
})
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
const order = options.order ?? 'desc';
const bucket = timeBucket.replace(/^[+-]/, '');
const query = this.db
.with('cte', (qb) =>
qb
@@ -869,7 +870,14 @@ export class AssetRepository {
return withBoundingBox(withBoundingCircle, bbox);
})
.where(truncatedDate(options.orderBy), '=', timeBucket.replace(/^[+-]/, ''))
.$if(options.orderBy === AssetOrderBy.CreatedAt, (qb) =>
qb
.where('asset.createdAt', '>=', sql<Date>`${bucket}::timestamp AT TIME ZONE 'UTC'`)
.where('asset.createdAt', '<', sql<Date>`(${bucket}::timestamp + interval '1 month') AT TIME ZONE 'UTC'`),
)
.$if(options.orderBy !== AssetOrderBy.CreatedAt, (qb) =>
qb.where(truncatedDate(options.orderBy), '=', bucket),
)
.$if(!!options.albumId, (qb) =>
qb.where((eb) =>
eb.exists(
@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { Kysely } from 'kysely';
import { AssetVisibility, SharedLinkType } from 'src/enum.js';
import { AssetOrderBy, AssetVisibility, SharedLinkType } from 'src/enum.js';
import { AccessRepository } from 'src/repositories/access.repository.js';
import { AssetRepository } from 'src/repositories/asset.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
@@ -162,6 +162,34 @@ describe(TimelineService.name, () => {
const rawResponse = await sut.getTimeBucket(factory.auth(), { timeBucket: '012345-01-01' });
const response = JSON.parse(rawResponse);
expect(response).toEqual(expect.objectContaining({ id: [] }));
const createdAtResponse = await sut.getTimeBucket(factory.auth(), {
timeBucket: '012345-01-01',
orderBy: AssetOrderBy.CreatedAt,
});
expect(JSON.parse(createdAtResponse)).toEqual(expect.objectContaining({ id: [] }));
});
it('should bucket by upload month when ordering by createdAt', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dates = [
'1970-01-31T23:59:59.999Z',
'1970-02-01T00:00:00.000Z',
'1970-02-28T23:59:59.999Z',
'1970-03-01T00:00:00.000Z',
];
const ids: string[] = [];
for (const createdAt of dates) {
const { asset } = await ctx.newAsset({ ownerId: user.id, createdAt, localDateTime: new Date('2000-01-01') });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
ids.push(asset.id);
}
const rawResponse = await sut.getTimeBucket(auth, { timeBucket: '1970-02-01', orderBy: AssetOrderBy.CreatedAt });
const response = JSON.parse(rawResponse);
expect(response.id).toEqual([ids[2], ids[1]]);
});
it('should return time bucket in trash', async () => {
+1
View File
@@ -164,6 +164,7 @@ export default defineConfig(
'@typescript-eslint/switch-exhaustiveness-check': ['error', { considerDefaultExhaustiveForUnions: true }],
'svelte/button-has-type': 'error',
'object-shorthand': ['error', 'always'],
'svelte/no-at-const-tags': 'error',
'svelte/no-navigation-without-resolve': 'off',
'unicorn/prefer-early-return': 'off',
'unicorn/prefer-ternary': 'off',
+2 -2
View File
@@ -36,8 +36,8 @@
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
"@photo-sphere-viewer/video-plugin": "^5.14.0",
"@tanstack/svelte-query": "^6.3.2",
"@simplewebauthn/browser": "14.0.0",
"@tanstack/svelte-query": "^6.3.2",
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.43.0",
"@zoom-image/svelte": "^0.3.0",
@@ -101,7 +101,7 @@
"eslint-plugin-better-tailwindcss": "^4.5.0",
"eslint-plugin-compat": "^7.0.0",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-svelte": "^3.12.4",
"eslint-plugin-svelte": "^3.23.1",
"eslint-plugin-unicorn": "^75.0.0",
"factory.ts": "^1.4.1",
"globals": "^17.0.0",
+4 -3
View File
@@ -31,7 +31,8 @@ import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { readPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { Route } from '$lib/route';
import { copyToClipboard } from '$lib/utils';
@@ -92,7 +93,7 @@ export const getPagesProvider = ($t: MessageFormatter) => {
title: $t('map'),
icon: mdiMapOutline,
onAction: () => goto(Route.map()),
$if: () => authManager.authenticated && featureFlagsManager.value.map,
$if: () => authManager.authenticated && readPreloadedQuery(getUserConfigQuery()).map.enabled,
},
{
title: $t('people'),
@@ -173,7 +174,7 @@ export const getPagesProvider = ($t: MessageFormatter) => {
title: $t('trash'),
icon: mdiTrashCanOutline,
onAction: () => goto(Route.trash()),
$if: () => authManager.authenticated && featureFlagsManager.value.trash,
$if: () => authManager.authenticated && readPreloadedQuery(getUserConfigQuery()).trash.enabled,
},
{
title: $t('admin.user_settings'),
@@ -70,9 +70,11 @@
</div>
{#each data.series as day, idx (day.date)}
{@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
{ month: 'short', day: 'numeric' },
{ locale: $locale },
{const date = $derived(
DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
{ month: 'short', day: 'numeric' },
{ locale: $locale },
),
)}
<div
class="aspect-square size-full rounded-sm {itemColors(day.count)} row-start-(--heatmap-row-start)"
@@ -49,7 +49,7 @@
{@render button()}
</div>
{:else}
{@const albumId = albumIds[0]}
{const albumId = $derived(albumIds[0])}
{#if albumId}
<AlbumThumbnail {albumId} onDelete={() => (albumIds = [])} />
{:else}
@@ -92,7 +92,7 @@
<Select options={schema.enum} bind:value={getString, setValue} />
</Field>
{:else if schema.array}
{@const values = getValue<string[]>([])}
{const values = $derived(getValue<string[]>([]))}
<Field {label} {description}>
<Input value={values.join(',')} disabled />
<HelperText>{$t('unsupported_field_type')}</HelperText>
@@ -7,7 +7,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { createPreloadedQuery, executeMutation } from '$lib/queries/client';
import { getConfigQuery, updateConfigMutation } from '$lib/queries/system-config.query';
import { getAdminConfigQuery, updateAdminConfigMutation } from '$lib/queries/config-admin.query';
import StorageTemplateVariablesModal from '$lib/modals/StorageTemplateVariablesModal.svelte';
import { Route } from '$lib/route';
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
@@ -40,7 +40,7 @@
type StorageTemplateOption = (typeof StorageTemplateOption)[keyof typeof StorageTemplateOption];
const disabled = $derived(featureFlagsManager.value.configFile);
const query = createPreloadedQuery(getConfigQuery);
const query = createPreloadedQuery(getAdminConfigQuery);
const config = $derived(query.data);
let configToEdit = $state(cloneDeep(config));
@@ -155,7 +155,7 @@
onDestroy(() => {
if (saveOnClose) {
void executeMutation(updateConfigMutation, { storageTemplate: configToEdit.storageTemplate });
void executeMutation(updateAdminConfigMutation, { storageTemplate: configToEdit.storageTemplate });
}
});
</script>
@@ -23,6 +23,8 @@
preload = false,
contextMenuItems,
}: Props = $props();
const owner = $derived(album.albumUsers[0].user);
</script>
<div
@@ -74,7 +76,6 @@
{/if}
{#if showOwner}
{@const owner = album.albumUsers[0].user}
{#if owner.id === authManager.user.id}
<p>{$t('owned')}</p>
{:else}
@@ -66,8 +66,8 @@
{#if !isCollapsed}
<div class="grid grid-auto-fill-56 gap-y-4" transition:slide={{ duration: 300 }}>
{#each albums as album, index (album.id)}
{@const { Edit, Share, Download, Leave, Delete } = getAlbumActions($t, album)}
{@const items = [Edit, Share, Download, Leave, Delete]}
{const { Edit, Share, Download, Leave, Delete } = $derived(getAlbumActions($t, album))}
{const items = $derived([Edit, Share, Download, Leave, Delete])}
<a
href={Route.viewAlbum(album)}
class="h-fit"
@@ -1,13 +1,13 @@
<script lang="ts">
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 { handleError } from '$lib/utils/handle-error';
import { getAlbumMapMarkersQuery } from '$lib/queries/album.query';
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 { mdiMapOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -15,17 +15,12 @@
}
let { album }: Props = $props();
let cancelable: AbortController;
let returnToMap = $state(false);
let mapMarkers: MapMarkerResponseDto[] = $state([]);
onMount(async () => {
mapMarkers = await loadMapMarkers();
});
const query = createQuery(() => getAlbumMapMarkersQuery(album.id));
const mapMarkers = $derived(query.data ?? []);
onDestroy(() => {
cancelable?.abort();
assetViewerManager.showAssetViewer(false);
});
@@ -38,18 +33,6 @@
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 assetIds = await modalManager.show(MapModal, { mapMarkers });
if (assetIds) {
@@ -1,9 +1,10 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo } from '@immich/sdk';
import { getAlbumQuery } from '$lib/queries/album.query';
import { queryClient } from '$lib/queries/client';
import { IconButton, Text, LoadingSpinner } from '@immich/ui';
import { mdiTrashCanOutline } from '@mdi/js';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
interface Props {
@@ -12,12 +13,18 @@
}
let { albumId, onDelete }: Props = $props();
const query = createQuery(
() => getAlbumQuery(albumId),
() => queryClient,
);
const album = $derived(query.data);
</script>
<div>
{#await getAlbumInfo({ ...authManager.params, id: albumId })}
{#if query.isPending}
<LoadingSpinner />
{:then album}
{:else if album}
<div class="flex gap-2">
<AlbumCover {album} class="size-24" />
<p
@@ -46,7 +53,7 @@
/>
</div>
</div>
{:catch}
{:else}
<div class="flex justify-between gap-2">
<div class="flex flex-col gap-1">
<Text>{$t('unknown')}</Text>
@@ -63,5 +70,5 @@
/>
</div>
</div>
{/await}
{/if}
</div>
@@ -7,7 +7,6 @@
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { handleDownloadAlbum } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
@@ -23,6 +22,11 @@
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import ThemeButton from '../shared-components/ThemeButton.svelte';
import AlbumSummary from './AlbumSummary.svelte';
import { createPreloadedQuery } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
const query = createPreloadedQuery(getPublicConfigQuery);
const publicConfig = $derived(query.data);
interface Props {
sharedLink: SharedLinkResponseDto;
@@ -144,7 +148,7 @@
icon={mdiDownload}
/>
{/if}
{#if sharedLink.showMetadata && featureFlagsManager.value.map}
{#if sharedLink.showMetadata && publicConfig.map.enabled}
<AlbumMap {album} />
{/if}
<ThemeButton />

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