Compare commits

...

12 Commits

Author SHA1 Message Date
Santo Shakil f53477e474 refactor: drop hashing, the core ships the two pixel ports 2026-07-07 00:58:46 +06:00
Santo Shakil b2b5841472 feat: add the 10 bit to 8888 convert to the core 2026-07-07 00:34:39 +06:00
Santo Shakil c0f9c50abe Merge remote-tracking branch 'origin/main' into feat/native-core 2026-07-07 00:34:39 +06:00
Santo Shakil 7d27eeceb6 refactor: rename ffi crate to immich_core_ffi 2026-07-05 01:55:32 +06:00
Santo Shakil bca556b6c5 fix: trust mise configs and ensure rust target in the build hook 2026-07-04 18:52:31 +06:00
Santo Shakil ead25d62b5 chore: show raw xcodebuild output for pr builds 2026-07-04 18:26:12 +06:00
Santo Shakil 8734066187 fix: record rust tool options in mise lockfile 2026-07-04 17:40:23 +06:00
Santo Shakil d2580e9d53 fix: preinstall rust cross targets via mise 2026-07-04 17:05:03 +06:00
Santo Shakil 07043c4af1 fix: add rust to mobile mise lockfile 2026-07-04 16:37:24 +06:00
Santo Shakil fec11ab156 fix: provide rustup via mise for mobile builds 2026-07-04 16:29:14 +06:00
Santo Shakil 3919887c2a fix: include host targets in rust toolchain file 2026-07-04 16:06:36 +06:00
Santo Shakil bb8e242fbe feat: add native rust core 2026-06-29 20:53:47 +06:00
36 changed files with 1915 additions and 0 deletions
+1
View File
@@ -5,3 +5,4 @@
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil
/native/ @santoshakil @mertalev
@@ -0,0 +1,75 @@
// Plumbing check: proves immich_native_core is usable from the real immich app on
// a real device — the build hook compiled the Rust for this target, the code asset
// bundled into the app, and the @Native symbols resolve at runtime. The two
// payloads run against their device-verified ground truth: the EXIF rotate ported
// from native_image.c (#29337) and the 10-bit convert matching Skia's Bitmap.copy
// (#29631). Calls the generated bindings directly — dart is the test harness here;
// the production callers are the platform decode pipelines.
// Self-contained: does NOT boot the immich app or need a server.
//
// Run: flutter test integration_test/native_core_test.dart -d <device>
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_native_core/immich_native_core.dart';
import 'package:integration_test/integration_test.dart';
Uint8List _px1010102(int r, int g, int b, int a) {
final px = (r & 0x3FF) | ((g & 0x3FF) << 10) | ((b & 0x3FF) << 20) | ((a & 0x3) << 30);
return Uint8List(4)..buffer.asByteData().setUint32(0, px, Endian.little);
}
Uint8List? _withBuffers(
Uint8List src,
int dstLen,
bool Function(Pointer<Uint8> src, int dstLen, Pointer<Uint8> dst) call,
) {
final srcPtr = malloc<Uint8>(src.length);
final dstPtr = malloc<Uint8>(dstLen);
try {
srcPtr.asTypedList(src.length).setAll(0, src);
if (!call(srcPtr, dstLen, dstPtr)) {
return null;
}
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
} finally {
malloc.free(srcPtr);
malloc.free(dstPtr);
}
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
test('native core loads: version roundtrips through the C string contract', () {
final ptr = immich_core_version();
expect(ptr, isNot(equals(nullptr)));
final version = ptr.cast<Utf8>().toDartString();
immich_core_free_string(ptr);
expect(version, isNotEmpty);
});
test('exif rotate (the #29337 algorithm) rotates 180 on device', () {
// 2x1: red, green -> 180 -> green, red
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
final out = _withBuffers(src, 8, (s, len, d) =>
immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 3, d, len));
expect(out, [0, 255, 0, 255, 255, 0, 0, 255]);
});
test('10-bit convert (the #29631 algorithm) matches Skia ground truth on device', () {
// 179->45 and 111->28 pin round(v*255/1023) over >>2 (44/27) — the exact
// discriminating values probed on this hardware against Bitmap.copy.
final src = Uint8List.fromList([
..._px1010102(1023, 0, 0, 3),
..._px1010102(179, 111, 0, 3),
]);
final out = _withBuffers(src, 8, (s, len, d) =>
immich_core_rgba1010102_to_rgba8888(s, src.length, 8, 2, 1, d, len));
expect(out, isNotNull);
expect(out!.sublist(0, 4), [255, 0, 0, 255]);
expect(out.sublist(4, 8), [45, 28, 0, 255]);
});
}
+14
View File
@@ -34,6 +34,15 @@ platform :ios do
)
end
# Xcode script phases don't inherit MISE_TRUSTED_CONFIG_PATHS, and the mise shim
# for rustup refuses untrusted configs, which kills the native assets build hook.
# `mise trust` persists trust to disk so it survives into the xcode environment.
def trust_mise_configs
return unless system("command -v mise > /dev/null 2>&1")
sh("mise trust ../../../mise.toml")
sh("mise trust ../../mise.toml")
end
# Helper method to assemble xcargs with optional CUSTOM_GROUP_ID override
def build_xcargs(group_id: nil)
args = "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual"
@@ -102,6 +111,8 @@ end
)
app_identifier = base_bundle_id
trust_mise_configs
# Set version number if provided
if version_number
increment_version_number(version_number: version_number)
@@ -258,6 +269,8 @@ end
api_key = get_api_key
trust_mise_configs
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
@@ -284,6 +297,7 @@ end
configuration: "Release",
export_method: "app-store",
skip_package_ipa: true,
xcodebuild_formatter: "", # raw xcodebuild output so script-phase errors show in CI logs
xcargs: build_xcargs(group_id: DEV_GROUP_ID),
export_options: {
provisioningProfiles: {
+7
View File
@@ -184,3 +184,10 @@ url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c55847602
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.rust]]
version = "1.92.0"
backend = "core:rust"
[tools.rust.options]
targets = "aarch64-apple-ios,aarch64-apple-ios-sim,aarch64-linux-android,armv7-linux-androideabi,x86_64-apple-ios,x86_64-linux-android"
+5
View File
@@ -1,6 +1,11 @@
[tools]
"aqua:flutter/flutter" = "3.44.1"
java = "21.0.2"
# immich_native_core builds from source via a dart build hook that drives rustup.
# mise bootstraps rustup + this toolchain; keep in sync with the crate's rust-toolchain.toml.
# targets are preinstalled here because mise exports RUSTUP_TOOLCHAIN, which makes
# rustup ignore the crate's rust-toolchain.toml (and its target list) at build time.
rust = { version = "1.92.0", targets = "armv7-linux-androideabi,aarch64-linux-android,x86_64-linux-android,aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios" }
[tools."github:CQLabs/homebrew-dcm"]
version = "1.37.0"
+23
View File
@@ -912,6 +912,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.2"
immich_native_core:
dependency: "direct main"
description:
path: "../native/immich_native_core"
relative: true
source: path
version: "0.1.0"
immich_ui:
dependency: "direct main"
description:
@@ -1124,6 +1131,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.19.1"
native_toolchain_rust:
dependency: transitive
description:
name: native_toolchain_rust
sha256: faa57d2258a3b0fd2a634054f54e4496c9fcbd971977e7d2b7e6916d56892857
url: "https://pub.dev"
source: hosted
version: "1.0.4+0"
native_video_player:
dependency: "direct main"
description:
@@ -1755,6 +1770,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:
+2
View File
@@ -39,6 +39,8 @@ dependencies:
hooks_riverpod: ^2.6.1
http: ^1.6.0
image_picker: ^1.2.1
immich_native_core:
path: ../native/immich_native_core
immich_ui:
path: './packages/ui'
intl: ^0.20.2
+5
View File
@@ -0,0 +1,5 @@
/target
smoke/*.node
# generated + committed (regen via `mise run codegen`):
# crates/immich_core_dart/include/immich_core.h (cbindgen)
# immich_native_core/lib/immich_native_core_bindings_generated.dart (ffigen)
+543
View File
@@ -0,0 +1,543 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "cbindgen"
version = "0.29.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
dependencies = [
"heck",
"indexmap",
"log",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn",
"tempfile",
"toml",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "ctor"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "immich_core"
version = "0.1.0"
[[package]]
name = "immich_core_ffi"
version = "0.1.0"
dependencies = [
"cbindgen",
"immich_core",
]
[[package]]
name = "immich_core_napi"
version = "0.1.0"
dependencies = [
"immich_core",
"napi",
"napi-build",
"napi-derive",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "napi"
version = "3.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b41bda2ac390efb5e8d22025d925ccc3f3807d8c1bea6d19b36127247c4b8f83"
dependencies = [
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash",
]
[[package]]
name = "napi-build"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61d66f70256ad5aef58659966064471d0ad90e2897bc36a5a5e0389c85aabc1e"
dependencies = [
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "5.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81b4b08f15eed7a2a20c3f4c6314013fc3ac890a3afa9892b594485299ebdb2d"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
dependencies = [
"libloading",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[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 = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+43
View File
@@ -0,0 +1,43 @@
[workspace]
resolver = "2"
members = [
"crates/immich_core",
"crates/immich_core_ffi",
"crates/immich_core_napi",
]
# shared logic lives in immich_core (no binding deps). each binding crate is a
# thin wrapper that picks its own crate-type: immich_core_ffi -> cdylib/staticlib,
# the C ABI consumed by dart (ffigen), swift (C interop) and kotlin (JNI shim);
# immich_core_napi -> cdylib (.node) for the node server.
# capabilities (image, ...) are cargo features on immich_core so both
# bindings opt into the same set. crate-type can't be feature-gated, which is why
# the bindings are separate crates rather than one crate with feature flags.
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "AGPL-3.0-only"
# single source of truth for all external dep versions. inner crates reference
# these with `{ workspace = true }` and never hardcode a version.
# default-features = false MUST live here (workspace level) — cargo ignores it if
# set only on the inner crate. inner crates then add the minimal features they need.
[workspace.dependencies]
napi = { version = "3", default-features = false }
napi-derive = "3"
napi-build = "2"
cbindgen = { version = "0.29", default-features = false }
# CI-enforced (not review-hoped): the boundary crate also #![deny]s unwrap/expect.
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
# NB: no `panic = "abort"` — the FFI boundary relies on catch_unwind, which is a
# no-op under abort. default unwind is what lets a boundary panic become a null
# return instead of taking down the host (Flutter app / node server).
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
+70
View File
@@ -0,0 +1,70 @@
# immich_native_core (PoC)
Shared Rust core for the pixel work the platforms can't do without native code,
built from source into the app via Flutter native assets.
The two capabilities are ports of native code we already ship or have in review,
so the same logic exists once, tested, instead of per-platform C:
- **EXIF-orientation rotate** — from `mobile/android/app/src/main/cpp/native_image.c`
(#29337, merged; fixes #24796, sideways RAW photos). Byte-for-byte the same affine
+ tiled copy, plus bounds checks the raw C can't have.
- **RGBA_1010102 → RGBA8888 convert** — the 10-bit HEIC/AVIF color fix (#29631,
fixes #24906). Same `round(v*255/1023)` LUT + packing as the C, proven on-device
against Skia's `Bitmap.copy(ARGB_8888)`.
Both fill a caller-owned output buffer (no allocation at the boundary) because the
production callers hold JNI-locked bitmaps. Nothing in the app calls the core yet;
wiring `native_image.c`'s JNI shims to it is the follow-up.
## Layout
```
crates/
immich_core pure logic, no binding deps. capabilities = cargo features (image).
immich_core_ffi the hand-written C ABI + cbindgen header — consumed by dart (ffigen),
swift (native C interop) and kotlin (JNI shim)
immich_core_napi cdylib (.node) via napi-rs (server, unwired)
immich_native_core/ the Flutter package mobile depends on. build hook + ffigen @Native bindings.
smoke/ host dart + node roundtrip scripts (no device)
```
Bindings are separate crates (Cargo can't gate `crate-type` by feature).
## How the native lib is built (Flutter native assets — no prebuilt, no CI)
`immich_native_core/hook/build.dart` (`native_toolchain_rust`) compiles
`crates/immich_core_ffi` **from source on every app build** via rustup and bundles
it as a Flutter *code asset*. The Dart side uses ffigen `@Native` externals bound to
that asset — no `DynamicLibrary`, no prebuilt artifacts, no fetch/publish/separate-repo.
Native assets is on by default on Flutter stable (3.38+), so a stock `flutter build`
runs the hook. Each builder needs **rustup** (the hook auto-installs the pinned
toolchain + targets from `crates/immich_core_ffi/rust-toolchain.toml`).
## Dev commands (mise)
```
mise run build cargo build --workspace
mise run test cargo test --workspace (host Rust tests, incl. FFI-boundary)
mise run lint clippy -D warnings (fmt: mise run fmt)
mise run codegen regen cbindgen header + ffigen @Native bindings — commit the result
mise run test:flutter HOST FFI roundtrip through the real build hook (no device)
mise run smoke Rust tests + host dart:ffi + host napi roundtrips
```
## Add a capability (end to end)
1. add the logic to `crates/immich_core` (behind a cargo feature if it pulls a dep).
2. expose a C entry in `crates/immich_core_ffi/src/lib.rs``#[no_mangle] pub extern "C"`,
wrap the body in `guard(...)`/`catch_unwind` (panic at the boundary → sentinel, never
unwind into the host), validate pointers, and either fill a caller-owned buffer or
return Rust-owned memory freed via `immich_core_free_string`.
3. `mise run codegen` — regenerates the committed cbindgen header + ffigen `@Native` bindings.
4. `mise run test:flutter` (host) + a case in `immich_native_core/test/` and in
`mobile/integration_test/native_core_test.dart` (device). The dart surface is the
generated bindings; add a hand-written dart wrapper only when a dart feature consumes it.
5. platform callers: kotlin via the existing JNI shim pattern (`NativeImage.kt`), swift
reads the same header natively.
## Consume from immich/mobile
`immich_native_core: { path: ../native/immich_native_core }` in `mobile/pubspec.yaml`,
then `dart pub get`. No app-level Gradle/Podfile edits — the hook builds + bundles the
lib. Builders need rustup. See the package README for the iOS App-Extension caveat.
`/native/` is codeowned by @santoshakil + @mertalev. License: reuses the immich
repo-root AGPL-3.0 (no separate license file).
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "immich_core"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["image"]
image = [] # pure pixel math, no deps
[lints]
workspace = true
+265
View File
@@ -0,0 +1,265 @@
//! EXIF-orientation rotation of RGBA8888 pixel buffers, ported from the Android
//! native_image.c (immich PR #29337). Lives here so the perf-critical pixel math
//! exists once, tested, callable from any platform's decode pipeline (Android RAW
//! today; the algorithm is platform-agnostic). The platform side keeps the bitmap
//! lock + output allocation and calls this to fill the destination buffer.
// EXIF orientation values (androidx ExifInterface.ORIENTATION_*).
const FLIP_HORIZONTAL: i32 = 2;
const ROTATE_180: i32 = 3;
const FLIP_VERTICAL: i32 = 4;
const TRANSPOSE: i32 = 5;
const ROTATE_90: i32 = 6;
const TRANSVERSE: i32 = 7;
const ROTATE_270: i32 = 8;
// 32x32 u32 tile = 4KB, L1-resident so a 90/270 transpose's scattered writes stay hot.
const TILE: usize = 32;
/// Whether the orientation swaps width and height (the 90/270 + transpose family).
pub fn swaps_dims(orientation: i32) -> bool {
matches!(orientation, ROTATE_90 | ROTATE_270 | TRANSPOSE | TRANSVERSE)
}
// (base, step_x, step_y): src pixel (sx,sy) maps to dst pixel index
// base + sx*step_x + sy*step_y for a destination of width `dw`. Mirrors
// native_image.c affine_for byte-for-byte. i64 so the math stays correct on 32-bit.
fn affine_for(o: i32, sw: i64, sh: i64, dw: i64) -> (i64, i64, i64) {
match o {
ROTATE_90 => (sh - 1, dw, -1),
ROTATE_270 => ((sw - 1) * dw, -dw, 1),
ROTATE_180 => ((sh - 1) * dw + (sw - 1), -1, -dw),
FLIP_HORIZONTAL => (sw - 1, -1, dw),
FLIP_VERTICAL => ((sh - 1) * dw, 1, -dw),
TRANSPOSE => (0, dw, 1),
TRANSVERSE => ((sw - 1) * dw + (sh - 1), -dw, -1),
_ => (0, 1, dw),
}
}
/// Rotate `src` (RGBA8888, `sh` rows of `src_stride` bytes, `sw` pixels per row) into
/// `dst` (densely packed, `dw*dh*4` bytes) for the given EXIF orientation, where
/// (dw,dh) swap for the 90/270/transpose family. Returns `false` without touching
/// out-of-range memory if the sizes are inconsistent, so the caller can fall back.
/// Indexing is bounds-checked: a bad input fails safe (panic caught at the FFI
/// boundary / false here), never an out-of-bounds write like the raw C.
pub fn rotate_rgba8888(
src: &[u8],
src_stride: usize,
sw: usize,
sh: usize,
orientation: i32,
dst: &mut [u8],
) -> bool {
if sw == 0 || sh == 0 || src_stride < sw * 4 {
return false;
}
let dw = if swaps_dims(orientation) { sh } else { sw };
let dh = if swaps_dims(orientation) { sw } else { sh };
if src.len() < src_stride * sh || dst.len() < dw * dh * 4 {
return false;
}
let (base, step_x, step_y) = affine_for(orientation, sw as i64, sh as i64, dw as i64);
for ty in (0..sh).step_by(TILE) {
let y_end = (ty + TILE).min(sh);
for tx in (0..sw).step_by(TILE) {
let x_end = (tx + TILE).min(sw);
for sy in ty..y_end {
let row = sy * src_stride;
let mut idx = base + sy as i64 * step_y + tx as i64 * step_x;
for sx in tx..x_end {
let s = row + sx * 4;
let d = idx as usize * 4;
dst[d..d + 4].copy_from_slice(&src[s..s + 4]);
idx += step_x;
}
}
}
}
true
}
// 10-bit -> 8-bit, matching Skia's Bitmap.copy(ARGB_8888): round(v * 255 / 1023).
// Compile-time LUT so it's one lookup per channel, not a mul+div per pixel. The
// integer form equals round-half-up for all 1024 inputs and v*255/1023 never lands
// on x.5, so it's exact for every value (verified on-device against Skia).
const SCALE10: [u8; 1024] = {
let mut lut = [0u8; 1024];
let mut v = 0usize;
while v < 1024 {
lut[v] = ((v as u32 * 255 + 511) / 1023) as u8;
v += 1;
}
lut
};
// 2-bit alpha -> 8-bit (a * 85). Photos decode opaque (a == 3 -> 255).
const ALPHA2: [u8; 4] = [0, 85, 170, 255];
/// Convert an Android RGBA_1010102 buffer (what a 10-bit HEIC/AVIF decodes to on
/// API 33+) to RGBA8888, byte-for-byte with Skia's `Bitmap.copy(ARGB_8888)`. Each
/// src pixel is a little-endian u32 with R in bits 0-9, G in 10-19, B in 20-29,
/// A in 30-31 (standard RGB10_A2 packing). `src` is `h` rows of `src_stride` bytes,
/// `dst` the caller's densely packed `w*h*4`. Returns false on inconsistent sizes
/// so the caller can fall back — same contract as [`rotate_rgba8888`], no alloc.
pub fn rgba1010102_to_rgba8888(
src: &[u8],
src_stride: usize,
w: usize,
h: usize,
dst: &mut [u8],
) -> bool {
if w == 0 || h == 0 || src_stride < w * 4 {
return false;
}
if src.len() < src_stride * h || dst.len() < w * h * 4 {
return false;
}
for y in 0..h {
let s_row = y * src_stride;
let d_row = y * w * 4;
for x in 0..w {
let s = s_row + x * 4;
// explicit little-endian — dodges an unaligned *const u32 read on non-x86
let px = u32::from_le_bytes([src[s], src[s + 1], src[s + 2], src[s + 3]]);
let d = d_row + x * 4;
dst[d] = SCALE10[(px & 0x3FF) as usize];
dst[d + 1] = SCALE10[((px >> 10) & 0x3FF) as usize];
dst[d + 2] = SCALE10[((px >> 20) & 0x3FF) as usize];
dst[d + 3] = ALPHA2[((px >> 30) & 0x3) as usize];
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
// Independent textbook EXIF transform: src(sx,sy) -> dst(dx,dy). Verifies the
// affine port against orientation *semantics*, not against itself.
fn ref_dst_xy(o: i32, sx: usize, sy: usize, sw: usize, sh: usize) -> (usize, usize) {
match o {
FLIP_HORIZONTAL => (sw - 1 - sx, sy),
ROTATE_180 => (sw - 1 - sx, sh - 1 - sy),
FLIP_VERTICAL => (sx, sh - 1 - sy),
TRANSPOSE => (sy, sx),
ROTATE_90 => (sh - 1 - sy, sx),
TRANSVERSE => (sh - 1 - sy, sw - 1 - sx),
ROTATE_270 => (sy, sw - 1 - sx),
_ => (sx, sy),
}
}
fn pixel(i: usize) -> [u8; 4] {
[
(i & 0xff) as u8,
((i >> 8) & 0xff) as u8,
((i >> 16) & 0xff) as u8,
0xff,
]
}
fn check(o: i32, sw: usize, sh: usize) {
let mut src = vec![0u8; sw * sh * 4];
for sy in 0..sh {
for sx in 0..sw {
let i = sy * sw + sx;
src[i * 4..i * 4 + 4].copy_from_slice(&pixel(i));
}
}
let (dw, dh) = if swaps_dims(o) { (sh, sw) } else { (sw, sh) };
let mut dst = vec![0u8; dw * dh * 4];
assert!(rotate_rgba8888(&src, sw * 4, sw, sh, o, &mut dst));
for sy in 0..sh {
for sx in 0..sw {
let (dx, dy) = ref_dst_xy(o, sx, sy, sw, sh);
let di = dy * dw + dx;
let si = sy * sw + sx;
assert_eq!(&dst[di * 4..di * 4 + 4], &pixel(si), "o={o} src({sx},{sy})");
}
}
}
#[test]
fn all_orientations_match_exif_reference() {
for o in [1, 2, 3, 4, 5, 6, 7, 8] {
check(o, 4, 3);
check(o, 1, 5);
check(o, 5, 1);
check(o, 40, 33); // spans multiple tiles
}
}
#[test]
fn identity_for_normal_orientation() {
let src: Vec<u8> = (0..24u8).collect(); // 2x3 RGBA
let mut dst = vec![0u8; 24];
assert!(rotate_rgba8888(&src, 8, 2, 3, 1, &mut dst));
assert_eq!(src, dst);
}
#[test]
fn respects_src_stride_padding() {
let (sw, sh, stride) = (2usize, 2usize, 12usize); // 4 bytes row padding
let mut src = vec![0u8; stride * sh];
for sy in 0..sh {
for sx in 0..sw {
let i = sy * sw + sx;
src[sy * stride + sx * 4..sy * stride + sx * 4 + 4].copy_from_slice(&pixel(i));
}
}
let mut dst = vec![0u8; sw * sh * 4];
assert!(rotate_rgba8888(&src, stride, sw, sh, ROTATE_180, &mut dst));
for i in 0..4 {
assert_eq!(&dst[i * 4..i * 4 + 4], &pixel(3 - i)); // 180: i -> N-1-i
}
}
#[test]
fn rejects_bad_sizes() {
let src = vec![0u8; 16];
let mut small = vec![0u8; 4];
assert!(!rotate_rgba8888(&src, 8, 2, 2, ROTATE_90, &mut small)); // dst too small
assert!(!rotate_rgba8888(&src, 4, 2, 2, 1, &mut small)); // stride < sw*4
}
// On-device (Pixel 9a) vs Skia's Bitmap.copy(ARGB_8888). 179/111 rule out `>> 2`.
#[test]
fn scale10_matches_skia() {
for (v, want) in [
(0, 0),
(111, 28),
(179, 45),
(230, 57),
(304, 76),
(1023, 255),
] {
assert_eq!(SCALE10[v], want, "SCALE10[{v}]");
}
assert_eq!(ALPHA2, [0, 85, 170, 255]);
}
// px = little-endian u32; R low 10 bits, A top 2.
#[test]
fn convert_packing() {
let red = 0x0000_03FFu32.to_le_bytes(); // R=1023, rest 0
let alpha = 0xC000_0000u32.to_le_bytes(); // A=3, rest 0
let mut src = Vec::new();
src.extend_from_slice(&red);
src.extend_from_slice(&alpha);
let mut dst = vec![0u8; 8];
assert!(rgba1010102_to_rgba8888(&src, 8, 2, 1, &mut dst));
assert_eq!(&dst[0..4], &[255, 0, 0, 0]); // opaque-less red
assert_eq!(&dst[4..8], &[0, 0, 0, 255]); // opaque black
}
#[test]
fn convert_rejects_bad_sizes() {
let src = vec![0u8; 16];
let mut small = vec![0u8; 4];
assert!(!rgba1010102_to_rgba8888(&src, 0, 0, 0, &mut small)); // zero dims
assert!(!rgba1010102_to_rgba8888(&src, 4, 2, 2, &mut small)); // stride < w*4
assert!(!rgba1010102_to_rgba8888(&src, 8, 2, 2, &mut small)); // dst too small
}
}
+23
View File
@@ -0,0 +1,23 @@
//! immich_native_core — shared Rust core for the immich server (napi) and mobile (dart:ffi).
//!
//! Pure logic only: no binding or platform deps live here. Each binding crate
//! (`immich_core_ffi`, `immich_core_napi`) is a thin wrapper. Capabilities are
//! cargo features (`image`, ...) so every binding opts into the same set.
#[cfg(feature = "image")]
pub mod image;
/// Version of the native core. Smoke-test entrypoint exercised by every binding.
pub fn core_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_is_present() {
assert!(!core_version().is_empty());
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "immich_core_ffi"
version.workspace = true
edition.workspace = true
license.workspace = true
# native_toolchain_rust requires cdylib (the bundled lib) + staticlib (iOS). It
# derives the artifact name from [package].name, so no [lib] name override here.
[lib]
crate-type = ["cdylib", "staticlib"]
# features pinned explicitly so the cbindgen header + ffigen bindings always
# match the exported symbols regardless of default-feature drift.
[dependencies]
immich_core = { path = "../immich_core", default-features = false, features = ["image"] }
[build-dependencies]
cbindgen = { workspace = true }
[lints]
workspace = true
+19
View File
@@ -0,0 +1,19 @@
use std::path::Path;
fn main() {
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let out = Path::new(&crate_dir).join("include").join("immich_core.h");
std::fs::create_dir_all(out.parent().unwrap()).ok();
// Hard-fail, not a warning: the CI drift gate diffs this header, so a silent
// codegen failure would let a stale header sail through green.
match cbindgen::generate(&crate_dir) {
Ok(bindings) => {
bindings.write_to_file(&out);
}
Err(e) => panic!("cbindgen failed: {e}"),
}
}
@@ -0,0 +1,3 @@
language = "C"
pragma_once = true
autogen_warning = "// Generated by cbindgen — do not edit."
@@ -0,0 +1,59 @@
#pragma once
// Generated by cbindgen — do not edit.
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
/**
* Native core version as a NUL-terminated UTF-8 string.
* Free the result with [`immich_core_free_string`].
*/
char *immich_core_version(void);
/**
* Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
* `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
* swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
* inconsistent sizes so the caller can fall back. The platform side owns the
* bitmap lock + the dst allocation; this only fills dst.
*
* # Safety
* `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
*/
bool immich_core_rotate_rgba8888(const uint8_t *src,
uintptr_t src_len,
uintptr_t src_stride,
uint32_t width,
uint32_t height,
int32_t orientation,
uint8_t *dst,
uintptr_t dst_len);
/**
* Convert an RGBA_1010102 image (`src`, `sh` rows of `src_stride` bytes) to
* RGBA8888 in the caller's densely-packed `w*h*4` `dst`, matching Skia's
* `Bitmap.copy(ARGB_8888)`. Returns false (a safe no-op) on null pointers or
* inconsistent sizes so the caller can fall back. The platform side owns the
* bitmap lock + the dst allocation; this only fills dst.
*
* # Safety
* `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
*/
bool immich_core_rgba1010102_to_rgba8888(const uint8_t *src,
uintptr_t src_len,
uintptr_t src_stride,
uint32_t width,
uint32_t height,
uint8_t *dst,
uintptr_t dst_len);
/**
* Release a string returned by this library.
*
* # Safety
* `ptr` must be a pointer previously returned by this library, or null.
*/
void immich_core_free_string(char *ptr);
@@ -0,0 +1,28 @@
# The build hook (native_toolchain_rust) drives cargo via rustup and auto-installs
# this toolchain + targets. Pin a version (never bare stable/beta) for reproducible
# builds. Keep the channel in sync with mise.toml's rust pin.
#
# Full default target set from native_toolchain_rust: the hook validates the HOST
# triple too (any dart/flutter command builds a host code asset), so linux + windows
# must stay in for CI runners and contributor machines, not just the app targets.
[toolchain]
channel = "1.92.0"
targets = [
# Android
"armv7-linux-androideabi",
"aarch64-linux-android",
"x86_64-linux-android",
# iOS (device + simulator)
"aarch64-apple-ios",
"aarch64-apple-ios-sim",
"x86_64-apple-ios",
# Windows
"aarch64-pc-windows-msvc",
"x86_64-pc-windows-msvc",
# Linux
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
# macOS
"aarch64-apple-darwin",
"x86_64-apple-darwin",
]
+153
View File
@@ -0,0 +1,153 @@
//! dart:ffi binding for immich_core (mobile).
//!
//! Returns heap-allocated C strings the caller must release with
//! `immich_core_free_string`. cbindgen emits `include/immich_core.h` at build time.
#![deny(clippy::unwrap_used, clippy::expect_used)]
use std::ffi::{c_char, CString};
use std::ptr;
/// Native core version as a NUL-terminated UTF-8 string.
/// Free the result with [`immich_core_free_string`].
#[no_mangle]
pub extern "C" fn immich_core_version() -> *mut c_char {
guard(ptr::null_mut(), || {
into_c_string(immich_core::core_version().to_owned())
})
}
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
/// inconsistent sizes so the caller can fall back. The platform side owns the
/// bitmap lock + the dst allocation; this only fills dst.
///
/// # Safety
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
#[no_mangle]
pub unsafe extern "C" fn immich_core_rotate_rgba8888(
src: *const u8,
src_len: usize,
src_stride: usize,
width: u32,
height: u32,
orientation: i32,
dst: *mut u8,
dst_len: usize,
) -> bool {
if src.is_null() || dst.is_null() {
return false;
}
// SAFETY: caller guarantees `src` is valid for reads of `src_len` bytes (see # Safety).
let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) };
// SAFETY: caller guarantees `dst` is valid for writes of `dst_len` bytes (see # Safety).
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
// AssertUnwindSafe: the closure writes through `&mut dst_slice`, which isn't
// UnwindSafe, but a panic mid-rotate only leaves dst partially written — not a
// broken invariant — and we return false so the caller discards the buffer.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
immich_core::image::rotate_rgba8888(
src_slice,
src_stride,
width as usize,
height as usize,
orientation,
dst_slice,
)
}))
.unwrap_or(false)
}
/// Convert an RGBA_1010102 image (`src`, `sh` rows of `src_stride` bytes) to
/// RGBA8888 in the caller's densely-packed `w*h*4` `dst`, matching Skia's
/// `Bitmap.copy(ARGB_8888)`. Returns false (a safe no-op) on null pointers or
/// inconsistent sizes so the caller can fall back. The platform side owns the
/// bitmap lock + the dst allocation; this only fills dst.
///
/// # Safety
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
#[no_mangle]
pub unsafe extern "C" fn immich_core_rgba1010102_to_rgba8888(
src: *const u8,
src_len: usize,
src_stride: usize,
width: u32,
height: u32,
dst: *mut u8,
dst_len: usize,
) -> bool {
if src.is_null() || dst.is_null() {
return false;
}
// SAFETY: caller guarantees `src` is valid for reads of `src_len` bytes (see # Safety).
let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) };
// SAFETY: caller guarantees `dst` is valid for writes of `dst_len` bytes (see # Safety).
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
// AssertUnwindSafe: a panic mid-convert only leaves dst partially written — not a
// broken invariant — and we return false so the caller discards the buffer.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
immich_core::image::rgba1010102_to_rgba8888(
src_slice,
src_stride,
width as usize,
height as usize,
dst_slice,
)
}))
.unwrap_or(false)
}
/// Release a string returned by this library.
///
/// # Safety
/// `ptr` must be a pointer previously returned by this library, or null.
#[no_mangle]
pub unsafe extern "C" fn immich_core_free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
guard((), || {
// SAFETY: `ptr` came from this library's `CString::into_raw` (see # Safety).
let s = unsafe { CString::from_raw(ptr) };
drop(s);
});
}
/// Run `f` at the FFI boundary, turning a panic into `sentinel` rather than
/// unwinding across `extern "C"` into the host. Guards panics only — a bad `len`
/// or a double/foreign free is caller-contract UB that stays the caller's
/// `# Safety` obligation, not something this can catch.
fn guard<T>(sentinel: T, f: impl FnOnce() -> T + std::panic::UnwindSafe) -> T {
std::panic::catch_unwind(f).unwrap_or(sentinel)
}
fn into_c_string(s: String) -> *mut c_char {
match CString::new(s) {
Ok(c) => c.into_raw(),
Err(_) => ptr::null_mut(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use std::ffi::CStr;
#[test]
fn version_roundtrips_and_frees() {
let p = immich_core_version();
assert!(!p.is_null());
// SAFETY: `p` is a non-null NUL-terminated string from this library.
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
assert!(!s.is_empty());
// SAFETY: `p` was returned by this library and is freed exactly once.
unsafe { immich_core_free_string(p) };
}
#[test]
fn free_null_is_noop() {
// SAFETY: free_string explicitly accepts null.
unsafe { immich_core_free_string(ptr::null_mut()) };
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "immich_core_napi"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
immich_core = { path = "../immich_core", default-features = false }
napi = { workspace = true, features = ["napi4", "dyn-symbols"] }
napi-derive = { workspace = true }
[build-dependencies]
napi-build = { workspace = true }
[lints]
workspace = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
napi_build::setup();
}
+12
View File
@@ -0,0 +1,12 @@
//! napi-rs binding for immich_core (node server).
//!
//! Built as a cdylib loaded as a `.node` addon — the same shape as the server's
//! existing native deps (sharp, bcrypt).
use napi_derive::napi;
/// Native core version. JS: `core.coreVersion()`.
#[napi]
pub fn core_version() -> String {
immich_core::core_version().to_owned()
}
+33
View File
@@ -0,0 +1,33 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/
+43
View File
@@ -0,0 +1,43 @@
# immich_native_core (Flutter package)
dart:ffi bindings to the `immich_native_core` Rust core. The native code is **built
from source on every app build** via a Dart build hook (Flutter native assets) — no
prebuilt binaries, no `DynamicLibrary`, no platform plugin glue.
## Use it from immich/mobile
```yaml
# mobile/pubspec.yaml
dependencies:
immich_native_core:
path: ../native/immich_native_core
```
`dart pub get`. The dart surface is exactly the ffigen output of the C header —
the current capabilities (EXIF rotate, 10-bit convert) are called by the platform
decode pipelines, not by dart, so there are no hand-written dart wrappers. Add one
only when a dart feature actually consumes a function.
No app-level Gradle/Podfile edits. `hook/build.dart` compiles the Rust crate and
Flutter bundles it as a code asset; the `@Native` bindings resolve against it.
**Requirement:** every machine that builds the app needs [rustup](https://rustup.rs)
— the hook auto-installs the pinned toolchain + targets from the crate's
`rust-toolchain.toml`.
## Layout
- `hook/build.dart` — builds `../crates/immich_core_ffi` via `native_toolchain_rust`.
- `lib/immich_native_core.dart` — barrel; re-exports the generated bindings.
- `lib/src/ffi/bindings.g.dart` — ffigen `@Native` output (committed; do not edit).
- `ffigen.yaml` — ffi-native mode; asset-id must match the hook's `assetName`.
- `test/` — host FFI roundtrip (`flutter test`); device runs via `mobile/integration_test`.
## ⚠ iOS App Extensions
Code assets are bundled into the app's **Runner** target. immich ships a Share
Extension and a Widget Extension — if the core is ever called from one of those,
verify the symbols resolve there (same family as the embed-into-Runner-only gotcha).
Not an issue while only the main app calls it.
The Rust workspace, the codegen/build/test commands, and the "add a function" loop
live in [`../README.md`](../README.md).
@@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+20
View File
@@ -0,0 +1,20 @@
# Regenerate: `mise run codegen` (cbindgen header -> ffigen @Native bindings).
# ffi-native mode emits top-level @Native externals + a library @DefaultAsset
# pointing at the code asset hook/build.dart produces — no DynamicLibrary loader.
# asset-id MUST equal the generated file's package URI (and the hook's assetName).
name: ImmichNativeCoreBindings
ffi-native:
asset-id: 'package:immich_native_core/src/ffi/bindings.g.dart'
description: 'FFI bindings to immich_native_core — generated, do not edit.'
output: 'lib/src/ffi/bindings.g.dart'
headers:
entry-points:
- '../crates/immich_core_ffi/include/immich_core.h'
include-directives:
- '**/immich_core.h'
functions:
include:
- 'immich_core_.*'
comments:
style: any
length: full
+61
View File
@@ -0,0 +1,61 @@
import 'dart:io';
import 'package:code_assets/code_assets.dart';
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_rust/native_toolchain_rust.dart';
// Builds crates/immich_core_ffi from source on every app build and bundles it as
// a code asset. assetName must match the ffigen output (its package URI is the
// @Native DefaultAsset id). The crate is a sibling, so point cratePath at it.
void main(List<String> args) async {
await build(args, (input, output) async {
await _ensureRustTarget(input);
await RustBuilder(
assetName: 'src/ffi/bindings.g.dart',
cratePath: '../crates/immich_core_ffi',
).run(input: input, output: output);
});
}
// rustup only auto-installs the rust-toolchain.toml targets when that file drives
// toolchain selection; with RUSTUP_TOOLCHAIN exported (mise does on CI) the file is
// bypassed and cross targets never install. Add the build target explicitly — a
// fast no-op when it's already present.
Future<void> _ensureRustTarget(BuildInput input) async {
final code = input.config.code;
final triple = switch (code.targetOS) {
OS.android => switch (code.targetArchitecture) {
Architecture.arm => 'armv7-linux-androideabi',
Architecture.arm64 => 'aarch64-linux-android',
Architecture.x64 => 'x86_64-linux-android',
_ => null,
},
OS.iOS => switch ((code.targetArchitecture, code.iOS.targetSdk)) {
(Architecture.arm64, IOSSdk.iPhoneSimulator) => 'aarch64-apple-ios-sim',
(Architecture.arm64, _) => 'aarch64-apple-ios',
(Architecture.x64, _) => 'x86_64-apple-ios',
_ => null,
},
OS.macOS => switch (code.targetArchitecture) {
Architecture.arm64 => 'aarch64-apple-darwin',
Architecture.x64 => 'x86_64-apple-darwin',
_ => null,
},
OS.linux => switch (code.targetArchitecture) {
Architecture.arm64 => 'aarch64-unknown-linux-gnu',
Architecture.x64 => 'x86_64-unknown-linux-gnu',
_ => null,
},
_ => null,
};
if (triple == null) return;
final crate = input.packageRoot
.resolve('../crates/immich_core_ffi/')
.toFilePath();
// best effort — if rustup itself is broken the build below reports it properly
await Process.run('rustup', [
'target',
'add',
triple,
], workingDirectory: crate);
}
@@ -0,0 +1,8 @@
/// dart:ffi bindings to the immich_native_core Rust core, built from source and
/// bundled via the Dart build hook. The dart surface is exactly the ffigen output
/// of the C header — the production callers of the current capabilities are the
/// platform decode pipelines (Kotlin/JNI, Swift), so there are no hand-written
/// dart wrappers; add one only when a dart feature actually consumes a function.
library;
export 'src/ffi/bindings.g.dart';
@@ -0,0 +1,80 @@
// AUTO GENERATED FILE, DO NOT EDIT.
//
// Generated by `package:ffigen`.
// ignore_for_file: type=lint, unused_import
@ffi.DefaultAsset('package:immich_native_core/src/ffi/bindings.g.dart')
library;
import 'dart:ffi' as ffi;
/// Native core version as a NUL-terminated UTF-8 string.
/// Free the result with [`immich_core_free_string`].
@ffi.Native<ffi.Pointer<ffi.Char> Function()>()
external ffi.Pointer<ffi.Char> immich_core_version();
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
/// inconsistent sizes so the caller can fall back. The platform side owns the
/// bitmap lock + the dst allocation; this only fills dst.
///
/// # Safety
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
@ffi.Native<
ffi.Bool Function(
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
ffi.UintPtr,
ffi.Uint32,
ffi.Uint32,
ffi.Int32,
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
)
>()
external bool immich_core_rotate_rgba8888(
ffi.Pointer<ffi.Uint8> src,
int src_len,
int src_stride,
int width,
int height,
int orientation,
ffi.Pointer<ffi.Uint8> dst,
int dst_len,
);
/// Convert an RGBA_1010102 image (`src`, `sh` rows of `src_stride` bytes) to
/// RGBA8888 in the caller's densely-packed `w*h*4` `dst`, matching Skia's
/// `Bitmap.copy(ARGB_8888)`. Returns false (a safe no-op) on null pointers or
/// inconsistent sizes so the caller can fall back. The platform side owns the
/// bitmap lock + the dst allocation; this only fills dst.
///
/// # Safety
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
@ffi.Native<
ffi.Bool Function(
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
ffi.UintPtr,
ffi.Uint32,
ffi.Uint32,
ffi.Pointer<ffi.Uint8>,
ffi.UintPtr,
)
>()
external bool immich_core_rgba1010102_to_rgba8888(
ffi.Pointer<ffi.Uint8> src,
int src_len,
int src_stride,
int width,
int height,
ffi.Pointer<ffi.Uint8> dst,
int dst_len,
);
/// Release a string returned by this library.
///
/// # Safety
/// `ptr` must be a pointer previously returned by this library, or null.
@ffi.Native<ffi.Void Function(ffi.Pointer<ffi.Char>)>()
external void immich_core_free_string(ffi.Pointer<ffi.Char> ptr);
+26
View File
@@ -0,0 +1,26 @@
name: immich_native_core
description: "dart:ffi bindings to the immich_native_core Rust core, built from source via Dart build hooks."
version: 0.1.0
homepage: https://github.com/immich-app/immich
publish_to: none
environment:
sdk: '>=3.11.0 <4.0.0'
flutter: '>=3.3.0'
# Not a platform plugin: the native lib is built + bundled by hook/build.dart as a
# code asset (Flutter native assets), so there is no ffiPlugin / android / ios dir.
dependencies:
flutter:
sdk: flutter
# build-hook deps — run at build time to compile the Rust crate (need rustup).
code_assets: ^1.2.1
hooks: ^2.0.2
native_toolchain_rust: ^1.0.4
dev_dependencies:
ffi: ^2.2.0 # tests only — the lib surface is the generated bindings (dart:ffi)
ffigen: 20.1.1 # pinned exact — a caret bump can re-emit bindings
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
@@ -0,0 +1,106 @@
// Host FFI roundtrip — `flutter test` builds the hook for the host platform and
// resolves the @Native symbols, no device needed. Calls the generated bindings
// directly (the package's actual surface); device runs: mobile/integration_test.
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_native_core/immich_native_core.dart';
Uint8List _px1010102(int r, int g, int b, int a) {
final px =
(r & 0x3FF) |
((g & 0x3FF) << 10) |
((b & 0x3FF) << 20) |
((a & 0x3) << 30);
return Uint8List(4)..buffer.asByteData().setUint32(0, px, Endian.little);
}
typedef _ImageFn =
bool Function(Pointer<Uint8> src, int dstLen, Pointer<Uint8> dst);
// malloc src+dst, run the native call, return the dst bytes (or null if it declined).
Uint8List? _withBuffers(Uint8List src, int dstLen, _ImageFn call) {
final srcPtr = malloc<Uint8>(src.length);
final dstPtr = malloc<Uint8>(dstLen);
try {
srcPtr.asTypedList(src.length).setAll(0, src);
if (!call(srcPtr, dstLen, dstPtr)) {
return null;
}
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
} finally {
malloc.free(srcPtr);
malloc.free(dstPtr);
}
}
void main() {
test('core loads: version roundtrips through the C string contract', () {
final ptr = immich_core_version();
expect(ptr, isNot(equals(nullptr)));
final version = ptr.cast<Utf8>().toDartString();
immich_core_free_string(ptr);
expect(version, isNotEmpty);
});
test('exif rotate: 180 reverses pixels, 90 swaps dims', () {
// 2x1 image: red, green (RGBA).
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
final r180 = _withBuffers(
src,
8,
(s, len, d) =>
immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 3, d, len),
);
expect(r180, [0, 255, 0, 255, 255, 0, 0, 255]); // green, red
final r90 = _withBuffers(
src,
8,
(s, len, d) =>
immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 6, d, len),
);
expect(r90, isNotNull); // 90 -> 1x2, same byte count
});
test('rotate declines bad sizes instead of writing', () {
final src = Uint8List(16);
final tooSmall = _withBuffers(
src,
4,
(s, len, d) =>
immich_core_rotate_rgba8888(s, src.length, 8, 2, 2, 6, d, len),
);
expect(tooSmall, isNull);
});
test('10-bit convert matches the on-device Skia ground truth', () {
// 179->45 and 111->28 pin round(v*255/1023); a plain >>2 would give 44/27.
final src = Uint8List.fromList([
..._px1010102(1023, 0, 0, 3),
..._px1010102(179, 111, 0, 3),
]);
final out = _withBuffers(
src,
8,
(s, len, d) =>
immich_core_rgba1010102_to_rgba8888(s, src.length, 8, 2, 1, d, len),
);
expect(out, isNotNull);
expect(out!.sublist(0, 4), [255, 0, 0, 255]);
expect(out.sublist(4, 8), [45, 28, 0, 255]);
});
test('convert declines bad sizes instead of writing', () {
final src = Uint8List(16);
final badStride = _withBuffers(
src,
16,
(s, len, d) =>
immich_core_rgba1010102_to_rgba8888(s, src.length, 4, 2, 2, d, len),
);
expect(badStride, isNull);
});
}
+68
View File
@@ -0,0 +1,68 @@
[tools]
rust = "1.92.0" # keep in sync with rust-toolchain.toml (the build hook uses rustup)
[tasks.build]
description = "Build all native core crates (host)"
run = "cargo build --workspace"
[tasks.test]
description = "Run native core Rust tests"
run = "cargo test --workspace"
[tasks.fmt]
description = "Format all crates"
run = "cargo fmt --all"
[tasks.lint]
description = "Clippy (warnings = errors)"
run = "cargo clippy --workspace --all-targets -- -D warnings"
# Regen the committed cbindgen header + ffigen @Native bindings.
[tasks."codegen:ffigen"]
alias = "codegen"
description = "Generate the C header (cbindgen) + Dart @Native bindings (ffigen)"
sources = [
"crates/immich_core_ffi/src/lib.rs",
"crates/immich_core_ffi/cbindgen.toml",
"immich_native_core/ffigen.yaml",
]
outputs = [
"crates/immich_core_ffi/include/immich_core.h",
"immich_native_core/lib/src/ffi/bindings.g.dart",
]
run = [
"cargo build -p immich_core_ffi",
"cd immich_native_core && dart run ffigen --config ffigen.yaml && dart format lib/src/ffi/bindings.g.dart",
]
# Host FFI roundtrip through the real build hook — no device. Builds the Rust crate
# via rustup + resolves the @Native code asset.
[tasks."test:flutter"]
description = "Host FFI roundtrip via the build hook (flutter test)"
dir = "immich_native_core"
run = "flutter test"
[tasks."build:dart"]
description = "Build the dart:ffi cdylib directly (host, raw cargo)"
run = "cargo build -p immich_core_ffi"
[tasks."build:napi"]
description = "Build the node addon + stage a .node for require() (server, unwired)"
run = [
"cargo build -p immich_core_napi --release",
"cp target/release/libimmich_core_napi.dylib smoke/immich_core_napi.node 2>/dev/null || cp target/release/libimmich_core_napi.so smoke/immich_core_napi.node",
]
[tasks."smoke:dart"]
description = "Host dart:ffi ABI roundtrip (raw DynamicLibrary on the cdylib)"
depends = ["build:dart"]
run = "dart run smoke/dart_smoke.dart target/debug/libimmich_core_ffi.dylib"
[tasks."smoke:node"]
description = "Host napi roundtrip"
depends = ["build:napi"]
run = "node smoke/node_smoke.mjs"
[tasks.smoke]
description = "Rust tests + host dart:ffi + host napi roundtrips"
depends = ["test", "smoke:dart", "smoke:node"]
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Cross-build the napi addon for Linux server (x86_64 + aarch64) via zigbuild
# (no Docker) and stage as .node under dist/server/<target>/.
# In CI you'd build these natively per-arch instead; this is local convenience.
set -euo pipefail
cd "$(dirname "$0")/.."
CRATE=immich_core_napi
for t in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
rustup target add "$t" >/dev/null 2>&1 || true
cargo zigbuild -p "$CRATE" --target "$t" --release
mkdir -p "dist/server/$t"
cp "target/$t/release/lib${CRATE}.so" "dist/server/$t/immich_core_napi.node"
done
echo "linux -> dist/server/*/immich_core_napi.node"
+31
View File
@@ -0,0 +1,31 @@
// Mobile-side roundtrip: open the dart:ffi cdylib and call into the shared core.
// Standalone script (no package:ffi dep) — reads the returned C string by hand.
//
// dart run smoke/dart_smoke.dart target/debug/libimmich_core_ffi.dylib
import 'dart:ffi';
typedef _VersionNative = Pointer<Uint8> Function();
typedef _FreeNative = Void Function(Pointer<Uint8>);
typedef _FreeDart = void Function(Pointer<Uint8>);
String _readCString(Pointer<Uint8> p) {
final bytes = <int>[];
for (var i = 0; p[i] != 0; i++) {
bytes.add(p[i]);
}
return String.fromCharCodes(bytes);
}
void main(List<String> args) {
final libPath = args.isNotEmpty ? args.first : 'target/debug/libimmich_core_ffi.dylib';
final lib = DynamicLibrary.open(libPath);
final version = lib.lookupFunction<_VersionNative, _VersionNative>('immich_core_version');
final free = lib.lookupFunction<_FreeNative, _FreeDart>('immich_core_free_string');
final ptr = version();
print('DART core_version = ${_readCString(ptr)}');
free(ptr);
print('DART roundtrip OK');
}
+13
View File
@@ -0,0 +1,13 @@
// Server-side roundtrip: load the napi addon and call into the shared core.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const core = require('./immich_core_napi.node');
const version = core.coreVersion();
console.log(`NAPI core_version = ${version}`);
if (!version) {
console.error('NAPI empty version');
process.exit(1);
}
console.log('NAPI roundtrip OK');