feat(mobile): jxl decoder (#32592)

This commit is contained in:
Mert
2026-10-24 11:52:05 +03:00
committed by Codex
parent 918d66fbdb
commit 70ef2884f0
68 changed files with 2971 additions and 290 deletions
+10
View File
@@ -0,0 +1,10 @@
# Link against minSdk (mobile/android/app/build.gradle) instead of the API 35 that native_toolchain_rust picks.
[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=--target=aarch64-linux-android26"]
# Neon on 32 bit arm is the NDK's default for C, the rust target leaves it off.
[target.armv7-linux-androideabi]
rustflags = ["-C", "link-arg=--target=armv7a-linux-androideabi26", "-C", "target-feature=+neon"]
[target.x86_64-linux-android]
rustflags = ["-C", "link-arg=--target=x86_64-linux-android26"]
+3
View File
@@ -0,0 +1,3 @@
/target
/crates/native_core_ffi/include/native_core.h
/native_core/lib/src/bindings.g.dart
+659
View File
@@ -0,0 +1,659 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "array-init"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cbindgen"
version = "0.29.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
dependencies = [
"heck",
"indexmap",
"log",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn 2.0.119",
"tempfile",
"toml",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "combine"
version = "4.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"log",
"simd_cesu8",
"thiserror",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "jxl"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8379fc83d79477900c9aed78cb8370a556d834081bebcb771c6010381c4e230"
dependencies = [
"array-init",
"byteorder",
"jxl_macros",
"jxl_simd",
"jxl_transforms",
"num-derive",
"num-traits",
"thiserror",
]
[[package]]
name = "jxl_macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeb27c06a9957bd9c8e65d0aeea0db6a555a9e1c85597e284f8304015c410c87"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "jxl_simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a645451b4d34b4017caf24110a14eb737e275b5d3d121bc754e67727ee65af0e"
[[package]]
name = "jxl_transforms"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21c4b7f8957f5ba578b2e28cd5ebbc97dad58a3ad5ff26765da61e8dab8f1cad"
dependencies = [
"jxl_simd",
]
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "moxcms"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396077b717181ce3f9a6b66739b269b82d756be5ec0f76be0bc40a7c51775830"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "native_core"
version = "0.1.0"
dependencies = [
"jxl",
"libc",
"moxcms",
"pic-scale",
"quick_cache",
"rayon",
]
[[package]]
name = "native_core_ffi"
version = "0.1.0"
dependencies = [
"cbindgen",
"jni",
"libc",
"native_core",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pic-scale"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick_cache"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "403c1a912fec895cafb223201e368234842acb9220aaf08ab042ae89ba5f135c"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.4",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+27
View File
@@ -0,0 +1,27 @@
[workspace]
resolver = "3"
members = ["crates/native_core", "crates/native_core_ffi"]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "AGPL-3.0-only"
[workspace.dependencies]
cbindgen = { version = "0.29", default-features = false }
jni = { version = "0.22", default-features = false }
jxl = { version = "0.8", default-features = false, features = ["neon"] }
libc = { version = "0.2", default-features = false }
moxcms = { version = "0.9", default-features = false, features = ["neon_luts", "neon_shaper_paths"] }
pic-scale = { version = "0.7", default-features = false, features = ["neon", "rdm"] }
quick_cache = { version = "0.7", default-features = false }
rayon = { version = "1.11", default-features = false }
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
# catch_unwind at the FFI boundary needs unwinding, so no panic = "abort" here.
[profile.release]
lto = true
codegen-units = 1
strip = true
+13
View File
@@ -0,0 +1,13 @@
# Native core
Rust code that ships inside the mobile app. Flutter compiles it from source through the
`native_core` build hook, so building the app needs a Rust toolchain: `mise install`
in `mobile/` sets one up, otherwise install [rustup](https://rustup.rs) once and the build
fetches the pinned toolchain itself.
- `crates/native_core`: the shared logic
- `crates/native_core_ffi`: the C ABI, cbindgen writes `include/native_core.h`
- `native_core`: Flutter package with the build hook and the ffigen bindings
(`NOTICES` carries the licenses of the crates compiled into the core, update it with the dependencies)
`mise run build`, `test`, `lint`, and `fmt`. The header and the Dart bindings come from `mise run //mobile:codegen:native`.
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "native_core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
jxl = { workspace = true }
libc = { workspace = true }
moxcms = { workspace = true }
pic-scale = { workspace = true }
quick_cache = { workspace = true }
rayon = { workspace = true }
[lints]
workspace = true
+92
View File
@@ -0,0 +1,92 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
pub use moxcms::{CicpColorPrimaries, CicpProfile, TransferCharacteristics};
use moxcms::{
ColorProfile, DataColorSpace, Layout, MatrixCoefficients, RenderingIntent,
Transform16BitExecutor, TransformOptions,
};
use crate::image::Pixels;
/// The colour space decoded pixels should be in.
#[derive(Clone, Copy, PartialEq)]
pub enum Target {
/// sRGB, converting and tone-mapping as needed.
Srgb,
/// The image's own space whenever CICP can describe it, otherwise sRGB.
Source,
}
pub(crate) const SRGB: CicpProfile = CicpProfile {
color_primaries: CicpColorPrimaries::Bt709,
transfer_characteristics: TransferCharacteristics::Srgb,
matrix_coefficients: MatrixCoefficients::Identity,
full_range: true,
};
pub(crate) fn is_srgb(cicp: CicpProfile) -> bool {
cicp.color_primaries == SRGB.color_primaries
&& cicp.transfer_characteristics == SRGB.transfer_characteristics
}
pub(crate) fn is_hdr(cicp: CicpProfile) -> bool {
matches!(
cicp.transfer_characteristics,
TransferCharacteristics::Smpte2084 | TransferCharacteristics::Hlg
)
}
/// Builds the transform from an ICC profile to sRGB, and the layout of the 12-bit samples it reads.
pub(crate) fn to_srgb(icc: &[u8]) -> Option<(Arc<Transform16BitExecutor>, Layout)> {
let source = ColorProfile::new_from_slice(icc).ok()?;
let layout = match source.color_space {
DataColorSpace::Gray => Layout::GrayAlpha,
_ => Layout::Rgba,
};
let options = TransformOptions {
// Profiles that tone-map HDR, like those jxl-rs makes, may only have a perceptual table.
rendering_intent: RenderingIntent::Perceptual,
// The fixed-point path loses precision with the narrower samples it reads.
prefer_fixed_point: false,
..Default::default()
};
// Going past 12-bit makes the transform's tables much slower to build for negligible benefit.
let transform = source
.create_transform_12bit(layout, &ColorProfile::new_srgb(), Layout::Rgba, options)
.ok()?;
Some((transform, layout))
}
/// Converts the 12-bit samples in `rgba`, `stride` bytes per row, to RGBA8 with `transform`.
pub(crate) fn convert_bands(
transform: &Transform16BitExecutor,
rgba: &mut Pixels<u8>,
stride: usize,
cancelled: &AtomicBool,
) -> Option<()> {
let (w, h) = (rgba.width(), rgba.height());
let buf = rgba.bytes_mut();
// Narrowing in place keeps peak memory at the wide decode plus one band.
let rows = 64;
let mut converted = vec![0u16; w * 4 * rows.min(h)];
for y in (0..h).step_by(rows) {
if cancelled.load(Ordering::Relaxed) {
return None;
}
let n = rows.min(h - y);
let band = &buf[y * stride..][..n * stride];
// SAFETY: any two bytes are a valid u16.
let ([], src, []) = (unsafe { band.align_to::<u16>() }) else {
return None;
};
transform.transform(src, &mut converted[..n * w * 4]).ok()?;
// Pixels decode at least as wide as RGBA8, so this only overwrites converted rows.
let narrowed = &mut buf[y * w * 4..][..n * w * 4];
for (dst, &src) in narrowed.iter_mut().zip(&converted) {
const NARROW: u32 = ((255 << 16) + 4095 / 2) / 4095;
*dst = ((u32::from(src) * NARROW + (1 << 15)) >> 16) as u8;
}
}
Some(())
}
+15
View File
@@ -0,0 +1,15 @@
/// Converts `h` rows of `w` RGBA_1010102 pixels, `stride` pixels apart in `src`, to dense RGBA_8888
/// in `dst`.
pub fn convert_1010102(src: &[u32], stride: usize, w: usize, h: usize, dst: &mut [u32]) {
// Each 10-bit channel rounds v*255/1023 (16.16 fixed point) and alpha maps to a*85. Plain
// arithmetic on purpose: the loop auto-vectorizes and beat a lookup table on-device.
for y in 0..h {
for (out, &px) in dst[y * w..][..w].iter_mut().zip(&src[y * stride..][..w]) {
let r = ((px & 0x3ff) * 16336 + 32768) >> 16;
let g = (((px >> 10) & 0x3ff) * 16336 + 32768) >> 16;
let b = (((px >> 20) & 0x3ff) * 16336 + 32768) >> 16;
let a = (px >> 30) * 85;
*out = r | (g << 8) | (b << 16) | (a << 24);
}
}
}
+202
View File
@@ -0,0 +1,202 @@
use std::marker::PhantomData;
use std::mem::ManuallyDrop;
use std::ptr::NonNull;
use std::slice;
use crate::color::CicpProfile;
use crate::resize::{self, Lanczos};
/// Decoded pixels and the colour space they are in.
pub struct Image {
pub cicp: CicpProfile,
pub samples: Samples,
}
pub enum Samples {
U8(Pixels<u8>),
/// For HDR transfers, which need more than 8 bits.
U16(Pixels<u16>),
}
/// Premultiplied RGBA pixels in a malloc'd buffer.
pub struct Pixels<T> {
/// Initialized bytes from calloc or realloc that only self references.
buf: NonNull<[u8]>,
width: usize,
height: usize,
sample: PhantomData<T>,
}
pub(crate) trait Sample: Lanczos + Ord {
const MAX: Self;
/// Rounds like dividing the product by MAX would.
fn scale(self, alpha: Self) -> Self;
}
impl Sample for u8 {
const MAX: Self = u8::MAX;
fn scale(self, alpha: Self) -> Self {
let x = u16::from(self) * u16::from(alpha) + 128;
((x + (x >> 8)) >> 8) as u8
}
}
impl Sample for u16 {
const MAX: Self = u16::MAX;
fn scale(self, alpha: Self) -> Self {
let x = u32::from(self) * u32::from(alpha) + 32768;
((x + (x >> 16)) >> 16) as u16
}
}
impl<T> Pixels<T> {
/// Allocates pixels of u8 or u16 samples, the only kinds this module makes, with `pixel_bytes`
/// each if a decoder first writes them wider.
pub(crate) fn zeroed(width: usize, height: usize, pixel_bytes: usize) -> Option<Self>
where
T: Sample,
{
let len = width
.checked_mul(height)?
.checked_mul(pixel_bytes.max(size_of::<[T; 4]>()))?;
// SAFETY: calloc has no preconditions.
let ptr = NonNull::new(unsafe { libc::calloc(len, 1) }.cast())?;
Some(Self {
buf: NonNull::slice_from_raw_parts(ptr, len),
width,
height,
sample: PhantomData,
})
}
pub(crate) fn bytes_mut(&mut self) -> &mut [u8] {
// SAFETY: self owns these initialized bytes.
unsafe { self.buf.as_mut() }
}
pub(crate) fn pixels_mut(&mut self) -> &mut [[T; 4]] {
// SAFETY: as for pixels, and self is borrowed mutably.
unsafe { slice::from_raw_parts_mut(self.buf.as_ptr().cast(), self.width * self.height) }
}
/// Frees everything after the pixels.
fn shrink(mut self) -> Option<Self> {
let len = self.width * self.height * 4 * size_of::<T>();
// SAFETY: the buffer is live and the pixels fill its first len bytes.
let shrunk = unsafe { libc::realloc(self.buf.as_ptr().cast(), len) };
// A failed realloc leaves the original buffer, which self still frees.
self.buf = NonNull::slice_from_raw_parts(NonNull::new(shrunk.cast())?, len);
Some(self)
}
/// Premultiplies if `alpha`, then resizes to `size`, or else frees everything after the pixels.
pub(crate) fn premultiply_resize(
mut self,
alpha: bool,
size: Option<(usize, usize)>,
) -> Option<Self>
where
T: Sample,
{
if alpha {
self.premultiply();
}
let Some(size) = size else {
return self.shrink();
};
let mut out = self.resize(size)?;
if alpha {
// Lanczos3 overshoots, while premultiplied colour must stay within alpha.
out.clamp();
}
Some(out)
}
fn premultiply(&mut self)
where
T: Sample,
{
// Alpha scaling itself at full opacity lets this vectorize as every lane does the same work.
for px in self.pixels_mut() {
let [r, g, b, a] = *px;
*px = [r.scale(a), g.scale(a), b.scale(a), a.scale(T::MAX)];
}
}
/// Clamps colour to alpha.
fn clamp(&mut self)
where
T: Sample,
{
for px in self.pixels_mut() {
let a = px[3];
*px = px.map(|c| c.min(a));
}
}
/// The pixels resampled to `size` with Lanczos3, in a new buffer.
fn resize(&self, (tw, th): (usize, usize)) -> Option<Self>
where
T: Sample,
{
let mut out = Self::zeroed(tw, th, size_of::<[T; 4]>())?;
resize::lanczos(self, &mut out)?;
Some(out)
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
pub fn pixels(&self) -> &[[T; 4]] {
// SAFETY: zeroed made room for at least these initialized pixels, and their u8 or u16 samples are
// valid for any bytes and aligned by malloc.
unsafe { slice::from_raw_parts(self.buf.as_ptr().cast(), self.width * self.height) }
}
/// Hands the buffer to a C caller, who frees it with free().
pub fn into_raw(self) -> *mut u8 {
ManuallyDrop::new(self).buf.as_ptr().cast()
}
}
impl<T> Drop for Pixels<T> {
fn drop(&mut self) {
// SAFETY: self owns the buffer, which came from calloc or realloc.
unsafe { libc::free(self.buf.as_ptr().cast()) };
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_colour_within_alpha() {
fn check<T: Sample>(half: T) {
let (zero, max) = (T::default(), T::MAX);
let mut px = Pixels::<T>::zeroed(16, 16, size_of::<[T; 4]>()).unwrap();
// Opaque black against translucent white rings both ways across the edge.
for (i, p) in px.pixels_mut().iter_mut().enumerate() {
*p = if i % 16 < 8 {
[zero, zero, zero, max]
} else {
[max, max, max, half]
};
}
let px = px.premultiply_resize(true, Some((5, 5))).unwrap();
for p in px.pixels() {
assert!(p[..3].iter().all(|&c| c <= p[3]), "{p:?}");
}
}
check(128u8);
check(32768u16);
}
}
+235
View File
@@ -0,0 +1,235 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use jxl::api::{
AlphaMode, Endianness, Event, ExtraChannel, JxlColorEncoding, JxlColorProfile, JxlColorType,
JxlDataFormat, JxlDecoder, JxlDecoderOptions, JxlOutputBuffer, JxlParallelRunner,
JxlParallelRunnerFun, JxlPixelFormat, JxlPrimaries, JxlTransferFunction, JxlWhitePoint,
};
use moxcms::{Layout, MatrixCoefficients, Transform16BitExecutor};
use rayon::prelude::*;
use crate::color::{self, CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
use crate::image::{Image, Pixels, Samples};
use crate::resize;
/// A JPEG XL image whose header has been read.
pub struct Jxl<'a> {
decoder: JxlDecoder,
input: &'a [u8],
/// The space of the output.
cicp: CicpProfile,
/// Converts wide samples to sRGB when the output is not in the source space.
transform: Option<Arc<Transform16BitExecutor>>,
}
/// Runs on the global pool with up to this many threads, or inline with one.
struct Rayon(usize);
impl JxlParallelRunner for Rayon {
fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> {
(0..num).into_par_iter().try_for_each(fun)
}
fn num_threads(&self) -> usize {
self.0
}
}
/// Stops handing out work once the flag is set.
struct Cancellable<'a> {
pool: Rayon,
cancelled: &'a AtomicBool,
}
impl JxlParallelRunner for Cancellable<'_> {
fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> {
self.pool.run(num, fun)
}
fn num_threads(&self) -> usize {
self.pool.num_threads()
}
// jxl-rs only calls this, and each of its tasks loops many items, so check cancellation here.
fn run_ordered(
&mut self,
num: usize,
max_threads: Option<usize>,
fun: &JxlParallelRunnerFun<'_>,
) -> jxl::error::Result<()> {
self.pool.run_ordered(num, max_threads, &|i| {
if self.cancelled.load(Ordering::Relaxed) {
// Any error stops the decode.
return Err(jxl::error::Error::ApiUsageError("cancelled"));
}
fun(i)
})
}
}
impl<'a> Jxl<'a> {
pub fn open(mut input: &'a [u8], target: Target) -> Option<Self> {
let mut options = JxlDecoderOptions::default();
// Colour transforms need straight alpha; premultiplying happens last.
options.alpha_mode = AlphaMode::Unpremultiplied;
let mut decoder = JxlDecoder::new(options);
let Event::BasicInfo = decoder.process(&mut input, None, None).ok()? else {
return None;
};
let extra = decoder.basic_info()?.extra_channels.len();
// An integer pixel format ensures the output profile below is sRGB rather than float scRGB.
decoder
.set_pixel_format(JxlPixelFormat::rgba8(extra))
.ok()?;
let profile = decoder.output_color_profile()?;
if let Some(cicp) = cicp(profile)
&& (target == Target::Source || color::is_srgb(cicp))
{
if color::is_hdr(cicp) {
decoder
.set_pixel_format(JxlPixelFormat::rgba16(extra))
.ok()?;
}
return Some(Self {
decoder,
input,
cicp,
transform: None,
});
}
let (transform, layout) = color::to_srgb(&profile.try_as_icc()?)?;
decoder
.set_pixel_format(JxlPixelFormat {
color_type: match layout {
Layout::GrayAlpha => JxlColorType::GrayscaleAlpha,
_ => JxlColorType::Rgba,
},
color_data_format: Some(JxlDataFormat::U16 {
endianness: Endianness::native(),
bit_depth: 12,
}),
extra_channel_format: vec![None; extra],
})
.ok()?;
Some(Self {
decoder,
input,
cicp: color::SRGB,
transform: Some(transform),
})
}
/// Decodes the first frame unless cancelled, downscaled to cover `target` pixels and to fit
/// within `max_pixel_size`.
pub fn decode(
mut self,
target: Option<(usize, usize)>,
max_pixel_size: usize,
cancelled: &AtomicBool,
) -> Option<Image> {
let info = self.decoder.basic_info()?;
let (w, h) = info.size;
let alpha = info
.extra_channels
.iter()
.any(|ec| ec.ec_type == ExtraChannel::Alpha);
let format = self.decoder.current_pixel_format()?;
let pixel_bytes =
format.color_type.samples_per_pixel() * format.color_data_format?.bytes_per_sample();
let stride = w.checked_mul(pixel_bytes)?;
let size = resize::cover((w, h), target, max_pixel_size);
let samples = if color::is_hdr(self.cicp) {
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
self.decode_frame(&mut rgba, stride, cancelled)?;
Samples::U16(rgba.premultiply_resize(alpha, size)?)
} else {
// The buffer is sized for the wide decode and ends up holding the RGBA8 result at its
// front.
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
self.decode_frame(&mut rgba, stride, cancelled)?;
if let Some(transform) = &self.transform {
color::convert_bands(transform.as_ref(), &mut rgba, stride, cancelled)?;
}
Samples::U8(rgba.premultiply_resize(alpha, size)?)
};
Some(Image {
cicp: self.cicp,
samples,
})
}
fn decode_frame<T>(
&mut self,
rgba: &mut Pixels<T>,
stride: usize,
cancelled: &AtomicBool,
) -> Option<()> {
let (w, h) = (rgba.width(), rgba.height());
let buf = rgba.bytes_mut();
// The pool only pays off for large images, since thumbs are already decoded in parallel.
let threads = if w * h > 512 * 512 {
rayon::current_num_threads()
} else {
1
};
let mut runner = Cancellable {
pool: Rayon(threads),
cancelled,
};
loop {
let output = &mut [JxlOutputBuffer::new(buf, h, stride)];
match self
.decoder
.process(&mut self.input, Some(output), Some(&mut runner))
.ok()?
{
Event::FrameHeader => {}
// Resizing costs ~10% of a thumbnail decode, wasted on tiles that scrolled away
// meanwhile.
Event::FrameComplete { .. } => {
return (!cancelled.load(Ordering::Relaxed)).then_some(());
}
_ => return None,
}
}
}
}
/// The space of the decoded samples, if CICP can describe it.
fn cicp(profile: &JxlColorProfile) -> Option<CicpProfile> {
let JxlColorProfile::Simple(encoding) = profile else {
return None;
};
let (primaries, transfer_function) = match encoding {
JxlColorEncoding::RgbColorSpace {
white_point: JxlWhitePoint::D65,
primaries,
transfer_function,
..
} => (primaries, transfer_function),
// Gray samples decode to neutral RGB, which any primaries reproduce.
JxlColorEncoding::GrayscaleColorSpace {
white_point: JxlWhitePoint::D65,
transfer_function,
..
} => (&JxlPrimaries::SRGB, transfer_function),
_ => return None,
};
Some(CicpProfile {
color_primaries: match primaries {
JxlPrimaries::SRGB => CicpColorPrimaries::Bt709,
JxlPrimaries::BT2100 => CicpColorPrimaries::Bt2020,
JxlPrimaries::P3 => CicpColorPrimaries::Smpte432,
JxlPrimaries::Chromaticities { .. } => return None,
},
transfer_characteristics: match transfer_function {
JxlTransferFunction::SRGB => TransferCharacteristics::Srgb,
JxlTransferFunction::PQ => TransferCharacteristics::Smpte2084,
JxlTransferFunction::HLG => TransferCharacteristics::Hlg,
_ => return None,
},
matrix_coefficients: MatrixCoefficients::Identity,
full_range: true,
})
}
+11
View File
@@ -0,0 +1,11 @@
pub mod color;
pub mod convert;
pub mod image;
pub mod jxl;
mod resize;
pub mod rotate;
pub mod thumbhash;
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
+103
View File
@@ -0,0 +1,103 @@
use std::fmt::Debug;
use std::sync::{Arc, LazyLock};
use pic_scale::{
ImageSize, ImageStore, ImageStoreMut, Resampling, ResamplingFunction, Scaler, WorkloadStrategy,
};
use quick_cache::sync::Cache;
use crate::image::Pixels;
/// Reuse the resampling plan for a given src-dst image size pair.
type Plans<T> = Cache<[usize; 4], Arc<Resampling<T, 4>>>;
const PLANS: usize = 16;
pub(crate) trait Lanczos: Copy + Default + Debug + 'static {
fn plan(from: ImageSize, to: ImageSize) -> Option<Arc<Resampling<Self, 4>>>;
fn cache() -> &'static Plans<Self>;
}
impl Lanczos for u8 {
fn plan(from: ImageSize, to: ImageSize) -> Option<Arc<Resampling<Self, 4>>> {
// The 8-bit speed path brightens by up to two levels and is barely faster.
Scaler::new(ResamplingFunction::Lanczos3)
.set_workload_strategy(WorkloadStrategy::PreferQuality)
.plan_rgba_resampling(from, to, false)
.ok()
}
fn cache() -> &'static Plans<Self> {
static CACHE: LazyLock<Plans<u8>> = LazyLock::new(|| Cache::new(PLANS));
&CACHE
}
}
impl Lanczos for u16 {
fn plan(from: ImageSize, to: ImageSize) -> Option<Arc<Resampling<Self, 4>>> {
Scaler::new(ResamplingFunction::Lanczos3)
.plan_rgba_resampling16(from, to, false, 16)
.ok()
}
fn cache() -> &'static Plans<Self> {
static CACHE: LazyLock<Plans<u16>> = LazyLock::new(|| Cache::new(PLANS));
&CACHE
}
}
/// The size of `size` scaled down to cover `target` and to fit within `max_pixel_size`, if that
/// is smaller.
pub(crate) fn cover(
(w, h): (usize, usize),
target: Option<(usize, usize)>,
max_pixel_size: usize,
) -> Option<(usize, usize)> {
// Scales as side ratios, so the side that sets the scale lands exactly on its limit.
let fit = (max_pixel_size, w.max(h));
let (num, den) = match target {
Some((tw, th)) => {
let cover = if tw * h >= th * w { (tw, w) } else { (th, h) };
if cover.0 * fit.1 < fit.0 * cover.1 {
cover
} else {
fit
}
}
None => fit,
};
(num < den).then(|| ((w * num).div_ceil(den), (h * num).div_ceil(den)))
}
/// Resamples `src` into `dst` with Lanczos3.
pub(crate) fn lanczos<T: Lanczos>(src: &Pixels<T>, dst: &mut Pixels<T>) -> Option<()> {
let [w, h, tw, th] = [src.width(), src.height(), dst.width(), dst.height()];
let from = ImageStore::borrow(src.pixels().as_flattened(), w, h).ok()?;
let mut to = ImageStoreMut::borrow(dst.pixels_mut().as_flattened_mut(), tw, th).ok()?;
to.bit_depth = 8 * size_of::<T>();
T::cache()
.get_or_insert_with(&[w, h, tw, th], || {
T::plan(from.size(), to.size()).ok_or(())
})
.ok()?
.resample(&from, &mut to)
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn covers_within_max_pixel_size() {
// A 720p thumbnail for a 266 px tile and for a larger target, and panoramas past the limit.
assert_eq!(cover((960, 720), Some((266, 266)), 16384), Some((355, 266)));
assert_eq!(cover((960, 720), Some((1000, 1000)), 16384), None);
assert_eq!(cover((40000, 1000), None, 16384), Some((16384, 410)));
assert_eq!(
cover((1000, 40000), Some((20000, 20000)), 16384),
Some((410, 16384))
);
}
}
+78
View File
@@ -0,0 +1,78 @@
#[derive(Clone, Copy)]
pub enum Orientation {
Normal,
FlipHorizontal,
Rotate180,
FlipVertical,
Transpose,
Rotate90,
Transverse,
Rotate270,
}
impl From<i32> for Orientation {
fn from(value: i32) -> Self {
match value {
2 => Self::FlipHorizontal,
3 => Self::Rotate180,
4 => Self::FlipVertical,
5 => Self::Transpose,
6 => Self::Rotate90,
7 => Self::Transverse,
8 => Self::Rotate270,
_ => Self::Normal,
}
}
}
impl Orientation {
pub fn dimensions(self, w: usize, h: usize) -> (usize, usize) {
match self {
Self::Transpose | Self::Rotate90 | Self::Transverse | Self::Rotate270 => (h, w),
_ => (w, h),
}
}
}
/// Copies `h` rows of `w` pixels, `stride` pixels apart in `src`, into `dst` with `orientation`
/// applied. `dst` is dense: `orientation.dimensions(w, h)` pixels.
pub fn rotate(
src: &[u32],
stride: usize,
w: usize,
h: usize,
orientation: Orientation,
dst: &mut [u32],
) {
assert!(w <= stride && h.saturating_mul(stride) <= src.len() && w * h <= dst.len());
let (dw, _) = orientation.dimensions(w, h);
let (w, h, dw) = (w as isize, h as isize, dw as isize);
// A source pixel (x, y) lands at base + x * step_x + y * step_y in the dense dw-wide output,
// the same layout as Bitmap.createBitmap(src, matrixForExifOrientation(orientation)).
let (base, step_x, step_y) = match orientation {
Orientation::FlipHorizontal => (w - 1, -1, dw),
Orientation::Rotate180 => ((h - 1) * dw + w - 1, -1, -dw),
Orientation::FlipVertical => ((h - 1) * dw, 1, -dw),
Orientation::Transpose => (0, dw, 1),
Orientation::Rotate90 => (h - 1, dw, -1),
Orientation::Transverse => ((w - 1) * dw + h - 1, -dw, -1),
Orientation::Rotate270 => ((w - 1) * dw, -dw, 1),
Orientation::Normal => (0, 1, dw),
};
let (src, dst) = (src.as_ptr(), dst.as_mut_ptr());
// 32x32 u32 tiles are 4KB, so the scattered writes of a 90/270 transpose stay in L1.
for ty in (0..h).step_by(32) {
for tx in (0..w).step_by(32) {
for y in ty..(ty + 32).min(h) {
// SAFETY: src holds h rows of stride pixels (asserted above).
let row = unsafe { src.add(y as usize * stride) };
let mut idx = base + y * step_y + tx * step_x;
for x in tx..(tx + 32).min(w) {
// SAFETY: the affine mapping stays within the w*h output and x stays within its row.
unsafe { dst.add(idx as usize).write(row.add(x as usize).read()) };
idx += step_x;
}
}
}
}
}
@@ -0,0 +1,91 @@
// Copyright (c) 2023 Evan Wallace
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
use std::f32::consts::PI;
pub fn decode(hash: &[u8], rgba: &mut [u8; 32 * 32 * 4]) -> Option<(u32, u32)> {
// Read the constants
let &[h0, h1, h2, h3, h4] = hash.first_chunk()?;
let header24 = u32::from(h0) | u32::from(h1) << 8 | u32::from(h2) << 16;
let header16 = u16::from_le_bytes([h3, h4]);
let l_dc = (header24 & 63) as f32 / 63.0;
let p_dc = ((header24 >> 6) & 63) as f32 / 31.5 - 1.0;
let q_dc = ((header24 >> 12) & 63) as f32 / 31.5 - 1.0;
let has_alpha = header24 >> 23 != 0;
let is_landscape = header16 >> 15 != 0;
let long = if has_alpha { 5 } else { 7 };
let short = usize::from(header16 & 7);
if short == 0 {
return None;
}
let lx = (if is_landscape { long } else { short }).max(3);
let ly = (if is_landscape { short } else { long }).max(3);
let alpha = if has_alpha { *hash.get(5)? } else { 15 };
let a_dc = (alpha & 15) as f32 / 15.0;
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
let ac_start = if has_alpha { 6 } else { 5 };
let mut ac_index = 0;
let mut channel = |nx, ny, scale| -> Option<[f32; 49]> {
let mut ac = [0.0; 49];
let mut j = 0;
for cy in 0..ny {
for _ in (usize::from(cy == 0)..nx).take_while(|cx| cx * ny < nx * (ny - cy)) {
let value = (hash.get(ac_start + (ac_index >> 1))? >> ((ac_index & 1) * 4)) & 15;
ac[j] = (value as f32 / 7.5 - 1.0) * scale;
ac_index += 1;
j += 1;
}
}
Some(ac)
};
let l_ac = channel(lx, ly, ((header24 >> 18) & 31) as f32 / 31.0)?;
let p_ac = channel(3, 3, ((header16 >> 3) & 63) as f32 / 63.0 * 1.25)?;
let q_ac = channel(3, 3, ((header16 >> 9) & 63) as f32 / 63.0 * 1.25)?;
let a_ac = if has_alpha {
channel(5, 5, (alpha >> 4) as f32 / 15.0)?
} else {
[0.0; 49]
};
// Decode using the DCT into RGB
let ratio = if is_landscape {
long as f32 / short as f32
} else {
short as f32 / long as f32
};
let w = (if ratio > 1.0 { 32.0 } else { 32.0 * ratio }).round() as u32;
let h = (if ratio > 1.0 { 32.0 / ratio } else { 32.0 }).round() as u32;
let channels = [(&l_ac, lx, ly), (&p_ac, 3, 3), (&q_ac, 3, 3), (&a_ac, 5, 5)];
let count = if has_alpha { 4 } else { 3 };
for y in 0..h {
for x in 0..w {
// Precompute the coefficients
let fx: [f32; 7] =
std::array::from_fn(|cx| (PI / w as f32 * (x as f32 + 0.5) * cx as f32).cos());
let fy: [f32; 7] =
std::array::from_fn(|cy| (PI / h as f32 * (y as f32 + 0.5) * cy as f32).cos());
let mut lpqa = [l_dc, p_dc, q_dc, a_dc];
for (sum, &(ac, nx, ny)) in lpqa.iter_mut().zip(&channels[..count]) {
let mut j = 0;
for (cy, fy) in fy.iter().enumerate().take(ny) {
let fy2 = fy * 2.0;
for cx in (usize::from(cy == 0)..nx).take_while(|cx| cx * ny < nx * (ny - cy)) {
*sum += ac[j] * fx[cx] * fy2;
j += 1;
}
}
}
// Convert to RGB
let [l, p, q, a] = lpqa;
let b = l - 2.0 / 3.0 * p;
let r = (3.0 * l - b + q) / 2.0;
let g = r - q;
let i = ((y * w + x) * 4) as usize;
for (dst, value) in rgba[i..i + 4].iter_mut().zip([r, g, b, a]) {
*dst = (255.0 * value.min(1.0)).round().max(0.0) as u8;
}
}
}
Some((w, h))
}
@@ -0,0 +1,32 @@
use native_core::convert::convert_1010102;
#[test]
fn channels_and_alpha() {
for (a, byte) in [(0, 0), (1, 85), (2, 170), (3, 255)] {
let src: [u32; 3] = [
(0x0ff << 10) | (0x3ff << 20) | (a << 30),
0x0ff | (0x3ff << 10) | (a << 30),
0x3ff | (0x0ff << 20) | (a << 30),
];
let mut dst = [0; 3];
convert_1010102(&src, 3, 3, 1, &mut dst);
assert_eq!(
dst,
[
0xff4000 | (byte << 24),
0x00ff40 | (byte << 24),
0x4000ff | (byte << 24)
]
);
}
}
#[test]
fn padded_rows() {
let src: [u32; 6] = [
0xc00003ff, 0xc00ffc00, 0xdeadbeef, 0xfff00000, 0xffffffff, 0xdeadbeef,
];
let mut dst = [0; 4];
convert_1010102(&src, 3, 2, 2, &mut dst);
assert_eq!(dst, [0xff0000ff, 0xff00ff00, 0xffff0000, 0xffffffff]);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+200
View File
@@ -0,0 +1,200 @@
use std::sync::atomic::AtomicBool;
use native_core::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
use native_core::image::{Image, Pixels, Samples};
use native_core::jxl::Jxl;
fn decode(data: &[u8], target: Target) -> Option<Image> {
Jxl::open(data, target)?.decode(None, 16384, &AtomicBool::new(false))
}
fn decode_srgb(data: &[u8]) -> Option<Pixels<u8>> {
let Image {
cicp:
CicpProfile {
color_primaries: CicpColorPrimaries::Bt709,
transfer_characteristics: TransferCharacteristics::Srgb,
..
},
samples: Samples::U8(rgba),
} = decode(data, Target::Srgb)?
else {
panic!("not decoded to sRGB");
};
Some(rgba)
}
#[test]
fn decodes_to_premultiplied_srgb() {
let cases: [(&str, &[u8], [u8; 8]); 5] = [
(
"sRGB with alpha",
include_bytes!("fixtures/srgb_alpha.jxl"),
[128, 0, 0, 128, 0, 0, 255, 255],
),
(
"Display P3",
include_bytes!("fixtures/p3.jxl"),
[255, 0, 0, 255, 255, 255, 255, 255],
),
(
"grayscale",
include_bytes!("fixtures/gray.jxl"),
[0, 0, 0, 255, 128, 128, 128, 255],
),
(
"grayscale with ICC profile",
include_bytes!("fixtures/gray_icc.jxl"),
[81, 81, 81, 255, 203, 203, 203, 255],
),
(
"animation",
include_bytes!("fixtures/animated.jxl"),
[255, 0, 0, 255, 255, 0, 0, 255],
),
];
for (name, data, expected) in cases {
let rgba = decode_srgb(data).expect(name);
assert_eq!(
(rgba.width(), rgba.height(), rgba.pixels().as_flattened()),
(2, 1, &expected[..]),
"{name}"
);
}
}
#[test]
fn converts_lossy_icc_to_srgb() {
// jxl-rs renders XYB straight to sRGB, while a JPEG transcode decodes in its ICC space and
// takes our transform.
for data in [
&include_bytes!("fixtures/xyb_icc.jxl")[..],
include_bytes!("fixtures/jpeg_icc.jxl"),
] {
let rgba = decode_srgb(data).unwrap();
for px in rgba.pixels() {
assert!(
px.iter()
.zip([210, 119, 65, 255])
.all(|(&a, b)| a.abs_diff(b) <= 3),
"{px:?}"
);
}
}
}
#[test]
fn tone_maps_hdr() {
let rgba = decode_srgb(include_bytes!("fixtures/pq_grays.jxl")).unwrap();
let levels: Vec<u8> = rgba
.pixels()
.iter()
.map(|px| {
assert!(
px[..3].iter().max().unwrap() - px[..3].iter().min().unwrap() <= 4,
"{px:?} is not neutral"
);
px[1]
})
.collect();
assert!(
levels.is_sorted_by(|a, b| a < b) && levels[2] < 255,
"{levels:?}"
);
}
#[test]
fn keeps_spaces_that_cicp_describes() {
let pq = decode(include_bytes!("fixtures/pq_alpha.jxl"), Target::Source);
let Some(Image {
cicp:
CicpProfile {
color_primaries: CicpColorPrimaries::Bt2020,
transfer_characteristics: TransferCharacteristics::Smpte2084,
..
},
samples: Samples::U16(rgba),
}) = pq
else {
panic!("not decoded to BT.2100 PQ");
};
assert_eq!(
rgba.pixels(),
[[20000, 15000, 10000, 32768], [49271, 49271, 49271, 65535]]
);
let p3 = decode(include_bytes!("fixtures/p3.jxl"), Target::Source);
let Some(Image {
cicp:
CicpProfile {
color_primaries: CicpColorPrimaries::Smpte432,
transfer_characteristics: TransferCharacteristics::Srgb,
..
},
samples: Samples::U8(rgba),
}) = p3
else {
panic!("not decoded to Display P3");
};
assert_eq!(rgba.pixels(), [[255, 0, 0, 255], [255, 255, 255, 255]]);
}
#[test]
fn converts_other_spaces_to_srgb() {
let data = include_bytes!("fixtures/gray_icc.jxl");
let Some(Image {
cicp:
CicpProfile {
color_primaries: CicpColorPrimaries::Bt709,
transfer_characteristics: TransferCharacteristics::Srgb,
..
},
samples: Samples::U8(rgba),
}) = decode(data, Target::Source)
else {
panic!("not decoded to sRGB");
};
assert_eq!(rgba.pixels(), decode_srgb(data).unwrap().pixels());
}
#[test]
fn downscales_to_cover() {
let data = include_bytes!("fixtures/jpeg_icc.jxl");
let Some(Image {
samples: Samples::U8(rgba),
..
}) =
Jxl::open(data, Target::Srgb)
.unwrap()
.decode(Some((8, 4)), 16384, &AtomicBool::new(false))
else {
panic!("not decoded to 8 bits");
};
// 16x16 covers 8x4 at 8x8, read through the wide decode buffer.
assert_eq!((rgba.width(), rgba.height()), (8, 8));
for px in rgba.pixels() {
assert!(
px.iter()
.zip([210, 119, 65, 255])
.all(|(&a, b)| a.abs_diff(b) <= 3),
"{px:?}"
);
}
}
#[test]
fn rejects_truncated_input() {
let data = include_bytes!("fixtures/srgb_alpha.jxl");
assert!(decode_srgb(&data[..data.len() / 2]).is_none());
}
#[test]
fn stops_when_cancelled() {
let data = include_bytes!("fixtures/large.jxl");
let decode = |cancelled| {
Jxl::open(data, Target::Srgb)
.unwrap()
.decode(None, 16384, &AtomicBool::new(cancelled))
.is_some()
};
assert_eq!((decode(false), decode(true)), (true, false));
}
+32
View File
@@ -0,0 +1,32 @@
use native_core::rotate::{Orientation, rotate};
#[test]
fn exif_orientations() {
let src = [1u32, 2, 3, 4, 5, 6];
let cases = [
(1, (3, 2), [1, 2, 3, 4, 5, 6]),
(2, (3, 2), [3, 2, 1, 6, 5, 4]),
(3, (3, 2), [6, 5, 4, 3, 2, 1]),
(4, (3, 2), [4, 5, 6, 1, 2, 3]),
(5, (2, 3), [1, 4, 2, 5, 3, 6]),
(6, (2, 3), [4, 1, 5, 2, 6, 3]),
(7, (2, 3), [6, 3, 5, 2, 4, 1]),
(8, (2, 3), [3, 6, 2, 5, 1, 4]),
(9, (3, 2), [1, 2, 3, 4, 5, 6]),
];
for (exif, size, expected) in cases {
let orientation = Orientation::from(exif);
let mut dst = [0; 6];
rotate(&src, 3, 3, 2, orientation, &mut dst);
assert_eq!(orientation.dimensions(3, 2), size);
assert_eq!(dst, expected, "orientation {exif}");
}
}
#[test]
fn padded_rows() {
let src = [1u32, 2, 3, 99, 4, 5, 6, 99];
let mut dst = [0; 6];
rotate(&src, 4, 3, 2, Orientation::Rotate90, &mut dst);
assert_eq!(dst, [4, 1, 5, 2, 6, 3]);
}
@@ -0,0 +1,97 @@
use native_core::thumbhash::decode;
// Expected pixels come from the old Swift decoder patched to round like the Java did (it truncated),
// taken at pixels whose values sit well away from a rounding boundary, so the check is exact on any libm.
const LANDSCAPE: &[u8] = &[
0xdc, 0xf7, 0x0d, 0x35, 0x84, 0x85, 0x79, 0x78, 0x7f, 0x77, 0x77, 0xa5, 0x77, 0x48, 0x87, 0x66,
0x86, 0x60, 0x57, 0x08, 0x76,
];
const PORTRAIT: &[u8] = &[
0x93, 0x4a, 0x06, 0x2d, 0x06, 0x92, 0x56, 0xc3, 0x74, 0x05, 0x58, 0x67, 0xda, 0x8a, 0xb6, 0x67,
0x94, 0x90, 0x51, 0x07, 0x19,
];
const ALPHA: &[u8] = &[
0x60, 0x9a, 0x86, 0x3d, 0x0c, 0x3b, 0xb0, 0x59, 0x6c, 0x96, 0xa8, 0x45, 0x69, 0xf4, 0x84, 0xf9,
0x0e, 0xa8, 0x27, 0x58, 0x76, 0x88, 0x70, 0x76, 0x47,
];
fn check(name: &str, hash: &[u8], size: (u32, u32), samples: &[(u32, u32, [u8; 4])]) {
let mut rgba = [0; 32 * 32 * 4];
let (width, height) = decode(hash, &mut rgba).unwrap();
assert_eq!((width, height), size, "{name}");
for &(x, y, expected) in samples {
let i = ((y * width + x) * 4) as usize;
assert_eq!(rgba[i..i + 4], expected, "{name} pixel {x},{y}");
}
}
#[test]
fn matches_swift_placeholders() {
check(
"landscape",
LANDSCAPE,
(32, 23),
&[
(0, 0, [48, 83, 154, 255]),
(11, 0, [59, 90, 156, 255]),
(20, 0, [91, 113, 163, 255]),
(29, 1, [109, 123, 156, 255]),
(1, 11, [84, 92, 96, 255]),
(10, 11, [77, 84, 89, 255]),
(20, 11, [143, 144, 141, 255]),
(30, 10, [164, 161, 153, 255]),
(1, 22, [98, 84, 35, 255]),
(9, 21, [69, 56, 12, 255]),
(19, 22, [87, 72, 30, 255]),
(30, 22, [79, 60, 13, 255]),
],
);
check(
"portrait",
PORTRAIT,
(23, 32),
&[
(0, 0, [27, 53, 53, 255]),
(5, 0, [57, 80, 87, 255]),
(14, 1, [41, 57, 57, 255]),
(22, 0, [42, 60, 37, 255]),
(0, 15, [149, 100, 11, 255]),
(7, 15, [166, 109, 28, 255]),
(14, 15, [181, 114, 19, 255]),
(22, 15, [204, 135, 10, 255]),
(0, 30, [70, 47, 0, 255]),
(7, 30, [106, 72, 18, 255]),
(13, 31, [135, 88, 19, 255]),
(22, 31, [159, 104, 0, 255]),
],
);
check(
"alpha",
ALPHA,
(32, 32),
&[
(1, 0, [227, 76, 54, 0]),
(10, 0, [147, 102, 114, 45]),
(19, 0, [130, 156, 130, 94]),
(31, 0, [192, 168, 23, 0]),
(0, 15, [232, 65, 49, 162]),
(10, 14, [142, 87, 106, 255]),
(20, 14, [141, 152, 119, 255]),
(31, 14, [231, 191, 50, 148]),
(0, 31, [255, 33, 10, 0]),
(11, 31, [213, 65, 76, 137]),
(20, 31, [209, 109, 64, 137]),
(31, 31, [255, 137, 0, 0]),
],
);
}
#[test]
fn rejects_truncated_hash() {
let mut rgba = [0; 32 * 32 * 4];
for len in 0..ALPHA.len() {
assert!(decode(&ALPHA[..len], &mut rgba).is_none(), "length {len}");
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "native_core_ffi"
version.workspace = true
edition.workspace = true
license.workspace = true
# native_toolchain_rust wants the explicit name and both crate types.
[lib]
name = "native_core_ffi"
crate-type = ["cdylib", "staticlib"]
[dependencies]
native_core = { path = "../native_core" }
libc = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
[build-dependencies]
cbindgen = { workspace = true }
[lints]
workspace = true
+13
View File
@@ -0,0 +1,13 @@
use std::path::Path;
fn main() {
// Files that define exported items. A directory here would make Flutter rerun the hook every build.
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=src/jxl.rs");
println!("cargo:rerun-if-changed=src/thumbhash.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
cbindgen::generate(&crate_dir)
.unwrap_or_else(|e| panic!("cbindgen failed: {e}"))
.write_to_file(Path::new(&crate_dir).join("include/native_core.h"));
}
@@ -0,0 +1,3 @@
language = "C"
pragma_once = true
autogen_warning = "// Generated by cbindgen. Do not edit."
@@ -0,0 +1,17 @@
# Keep the channel in sync with the rust entries in native/mise.toml and mobile/mise.toml.
[toolchain]
channel = "1.99.1"
targets = [
"aarch64-linux-android",
"armv7-linux-androideabi",
"x86_64-linux-android",
"aarch64-apple-ios",
"aarch64-apple-ios-sim",
"x86_64-apple-ios",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"aarch64-pc-windows-msvc",
"x86_64-pc-windows-msvc",
]
@@ -0,0 +1,83 @@
use jni::EnvUnowned;
use jni::errors::ThrowRuntimeExAndDefault;
use jni::objects::{JByteBuffer, JClass, JObject};
use jni::sys::{jint, jlong, jobject};
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_allocate<'caller>(
_env: EnvUnowned<'caller>,
_class: JClass<'caller>,
size: jint,
) -> jlong {
// SAFETY: malloc accepts any size.
unsafe { libc::malloc(size as usize) as jlong }
}
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_free<'caller>(
_env: EnvUnowned<'caller>,
_class: JClass<'caller>,
address: jlong,
) {
// SAFETY: the caller passes null or a live C allocation it owns.
unsafe { libc::free(address as *mut _) };
}
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_realloc<'caller>(
_env: EnvUnowned<'caller>,
_class: JClass<'caller>,
address: jlong,
size: jint,
) -> jlong {
// SAFETY: the caller passes null or a live C allocation it owns.
unsafe { libc::realloc(address as *mut _, size as usize) as jlong }
}
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_wrap<'caller>(
env: EnvUnowned<'caller>,
_class: JClass<'caller>,
address: jlong,
capacity: jint,
) -> jobject {
// jni's new_direct_byte_buffer rejects null and clears the java exception, the C did neither.
let raw = env.as_raw();
// SAFETY: raw is the VM's JNIEnv and android JNI is at least 1.6, so the 1.4 table exists.
unsafe { ((**raw).v1_4.NewDirectByteBuffer)(raw, address as *mut _, capacity as jlong) }
}
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_copy<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
buffer: JByteBuffer<'caller>,
dest: jlong,
offset: jint,
length: jint,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let Ok(src) = env.get_direct_buffer_address(&buffer) {
// SAFETY: the caller supplies valid, non-overlapping ranges for the copy.
unsafe {
let src = src.offset(offset as isize).cast();
libc::memcpy(dest as *mut _, src, length as usize);
}
}
Ok(())
})
.resolve::<ThrowRuntimeExAndDefault>();
}
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeBuffer_createGlobalRef<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
obj: JObject<'caller>,
) -> jlong {
env.with_env(|env| -> jni::errors::Result<_> {
// Transfer the global reference to the caller without deleting it.
Ok(env.new_global_ref(obj).map_or(0, |r| r.into_raw() as jlong))
})
.resolve::<ThrowRuntimeExAndDefault>()
}
@@ -0,0 +1,91 @@
use std::ffi::c_void;
use std::mem::MaybeUninit;
use std::{ptr, slice};
use jni::EnvUnowned;
use jni::errors::ThrowRuntimeExAndDefault;
use jni::objects::{JIntArray, JObject};
use jni::sys::{JNIEnv, jlong, jobject};
pub const FORMAT_RGBA_8888: i32 = 1;
pub const FORMAT_RGBA_1010102: i32 = 10;
const RESULT_SUCCESS: i32 = 0;
#[repr(C)]
struct Info {
width: u32,
height: u32,
stride: u32,
format: i32,
flags: u32,
}
#[link(name = "jnigraphics")]
unsafe extern "C" {
#[link_name = "AndroidBitmap_getInfo"]
fn get_info(env: *mut JNIEnv, bitmap: jobject, info: *mut Info) -> i32;
#[link_name = "AndroidBitmap_lockPixels"]
fn lock_pixels(env: *mut JNIEnv, bitmap: jobject, pixels: *mut *mut c_void) -> i32;
#[link_name = "AndroidBitmap_unlockPixels"]
fn unlock_pixels(env: *mut JNIEnv, bitmap: jobject) -> i32;
}
/// Runs `f(src, stride, width, height, dst)` over the pixels of a `format` bitmap, writing into a
/// malloc'd dense buffer of `size(width, height)` pixels that Kotlin frees. Fills `out_info` with
/// {width, height, rowBytes} of that buffer. Returns 0 when the bitmap can't be handled.
pub fn transform<'caller>(
mut env: EnvUnowned<'caller>,
bitmap: JObject<'caller>,
out_info: JIntArray<'caller>,
format: i32,
size: impl Fn(usize, usize) -> (usize, usize),
f: impl FnOnce(&[u32], usize, usize, usize, &mut [u32]),
) -> jlong {
let mut dst = ptr::null_mut();
let outcome = env.with_env(|env| -> jni::errors::Result<_> {
let raw_env = env.get_raw();
let mut info = MaybeUninit::uninit();
// SAFETY: JNI owns the bitmap reference and info is writable.
if unsafe { get_info(raw_env, bitmap.as_raw(), info.as_mut_ptr()) } != RESULT_SUCCESS {
return Ok(0);
}
// SAFETY: get_info initialized info on success.
let info = unsafe { info.assume_init() };
if info.format != format {
return Ok(0);
}
let (w, h) = (info.width as usize, info.height as usize);
let stride = info.stride as usize / 4;
let (dw, dh) = size(w, h);
// SAFETY: malloc accepts the byte size of this valid Android bitmap.
dst = unsafe { libc::malloc(dw * dh * 4) };
if dst.is_null() {
return Ok(0);
}
let mut pixels = ptr::null_mut();
// SAFETY: the bitmap reference is valid and pixels is writable.
if unsafe { lock_pixels(raw_env, bitmap.as_raw(), &mut pixels) } != RESULT_SUCCESS {
return Ok(0);
}
// SAFETY: a locked 4 byte format bitmap holds h aligned rows of stride pixels, and the
// malloc above holds dw * dh pixels.
let (src, out) = unsafe {
(
slice::from_raw_parts(pixels.cast::<u32>(), h * stride),
slice::from_raw_parts_mut(dst.cast::<u32>(), dw * dh),
)
};
f(src, stride, w, h, out);
// SAFETY: balances the successful lock above.
unsafe { unlock_pixels(raw_env, bitmap.as_raw()) };
out_info.set_region(env, 0, &[dw as i32, dh as i32, (dw * 4) as i32])?;
Ok(dst as jlong)
});
let result = outcome.resolve::<ThrowRuntimeExAndDefault>();
if result == 0 {
// SAFETY: dst is null or the malloc allocation that has not been returned to Kotlin.
unsafe { libc::free(dst) };
}
result
}
@@ -0,0 +1,23 @@
use jni::EnvUnowned;
use jni::objects::{JClass, JIntArray, JObject};
use jni::sys::jlong;
use native_core::convert::convert_1010102;
use super::bitmap::{self, FORMAT_RGBA_1010102};
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeImage_convert1010102<'caller>(
env: EnvUnowned<'caller>,
_class: JClass<'caller>,
bitmap: JObject<'caller>,
out_info: JIntArray<'caller>,
) -> jlong {
bitmap::transform(
env,
bitmap,
out_info,
FORMAT_RGBA_1010102,
|w, h| (w, h),
convert_1010102,
)
}
+162
View File
@@ -0,0 +1,162 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::{ptr, slice};
use libc::size_t;
#[cfg(target_os = "android")]
use jni::{
EnvUnowned,
errors::ThrowRuntimeExAndDefault,
objects::{JByteBuffer, JClass, JIntArray},
sys::{jint, jlong},
};
use native_core::color::Target;
use native_core::image::{Image, Samples};
use native_core::jxl::Jxl;
use super::guard;
/// The size and colour space of decoded pixels.
#[repr(C)]
#[derive(Default)]
pub struct NativeCoreImageInfo {
pub width: i32,
pub height: i32,
/// 8 or 16, for samples in native byte order.
pub bit_depth: u8,
/// ITU-T H.273 colour primaries.
pub primaries: u8,
/// ITU-T H.273 transfer characteristics.
pub transfer: u8,
}
/// Decodes a JPEG XL image to premultiplied RGBA in sRGB, or with `keep_space`, in the image's own
/// space when it has BT.709, BT.2020 or Display P3 primaries with the sRGB, PQ or HLG transfer.
/// Unless either is 0, it is downscaled to cover `target_width` x `target_height`, and it never
/// exceeds `max_pixel_size` pixels per side. `info` gets the size and space. The caller frees the
/// returned buffer with free(). Returns null on failure, or once `native_core_cancel` was called on
/// `cancelled`.
///
/// # Safety
/// `data` must be null or readable for `len` bytes, at most `isize::MAX`.
/// `cancelled` must be null or valid for the call and only changed through `native_core_cancel`.
/// `info` must be null or a valid writable pointer, outside `data`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn native_core_jxl_decode(
data: *const u8,
len: size_t,
keep_space: bool,
target_width: u32,
target_height: u32,
max_pixel_size: u32,
cancelled: *mut bool,
info: *mut NativeCoreImageInfo,
) -> *mut u8 {
guard(ptr::null_mut(), || {
if data.is_null() || cancelled.is_null() || info.is_null() {
return ptr::null_mut();
}
// SAFETY: the caller keeps the flag alive for the call and only changes it atomically.
let cancelled = unsafe { AtomicBool::from_ptr(cancelled) };
// SAFETY: the caller provides a buffer readable for len bytes.
let input = unsafe { slice::from_raw_parts(data, len) };
let target = if keep_space {
Target::Source
} else {
Target::Srgb
};
let cover = (target_width > 0 && target_height > 0)
.then_some((target_width as usize, target_height as usize));
let Some(Image { cicp, samples }) = Jxl::open(input, target)
.and_then(|image| image.decode(cover, max_pixel_size as usize, cancelled))
else {
return ptr::null_mut();
};
let (width, height, bit_depth, pixels) = match samples {
Samples::U8(rgba) => (rgba.width(), rgba.height(), 8, rgba.into_raw()),
Samples::U16(rgba) => (rgba.width(), rgba.height(), 16, rgba.into_raw()),
};
// SAFETY: the output is writable.
unsafe {
*info = NativeCoreImageInfo {
width: width as i32,
height: height as i32,
bit_depth,
primaries: cicp.color_primaries as u8,
transfer: cicp.transfer_characteristics as u8,
};
}
pixels
})
}
/// Asks the decode given `flag` to stop early.
///
/// # Safety
/// `flag` must be null or valid and otherwise only read by `native_core_jxl_decode`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
if !flag.is_null() {
// SAFETY: the caller guarantees the flag is valid and only accessed atomically.
unsafe { AtomicBool::from_ptr(flag) }.store(true, Ordering::Relaxed);
}
}
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeImage_decodeJxl<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
data: jlong,
length: jint,
target_width: jint,
target_height: jint,
max_pixel_size: jint,
cancelled: JByteBuffer<'caller>,
info: JIntArray<'caller>,
) -> jlong {
env.with_env(|env| -> jni::errors::Result<jlong> {
let flag = env.get_direct_buffer_address(&cancelled)?.cast::<bool>();
let mut decoded = NativeCoreImageInfo::default();
// SAFETY: Kotlin passes a live native buffer of `length` bytes and a direct buffer it only
// changes through `cancel`, and the output is valid.
let rgba = unsafe {
native_core_jxl_decode(
data as *const u8,
length as size_t,
false,
target_width as u32,
target_height as u32,
max_pixel_size as u32,
flag,
&mut decoded,
)
};
if !rgba.is_null()
&& let Err(err) =
info.set_region(env, 0, &[decoded.width, decoded.height, decoded.width * 4])
{
// SAFETY: this malloc buffer has not been transferred to Kotlin.
unsafe { libc::free(rgba.cast()) };
return Err(err);
}
Ok(rgba as jlong)
})
.resolve::<ThrowRuntimeExAndDefault>()
}
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeImage_cancel<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
flag: JByteBuffer<'caller>,
) {
env.with_env(|env| -> jni::errors::Result<()> {
let flag = env.get_direct_buffer_address(&flag)?.cast::<bool>();
// SAFETY: the buffer is the live one-byte flag Kotlin passed to `decodeJxl`.
unsafe { native_core_cancel(flag) };
Ok(())
})
.resolve::<ThrowRuntimeExAndDefault>();
}
+68
View File
@@ -0,0 +1,68 @@
use std::ffi::{CString, c_char};
use std::panic::{self, UnwindSafe};
use std::ptr;
#[cfg(target_os = "android")]
mod android;
/// cbindgen:ignore
#[cfg(target_os = "android")]
mod bitmap;
#[cfg(target_os = "android")]
mod convert;
mod jxl;
#[cfg(target_os = "android")]
mod rotate;
mod thumbhash;
/// Returns the core version as a C string. Free it with `native_core_free_string`.
#[unsafe(no_mangle)]
pub extern "C" fn native_core_version() -> *mut c_char {
guard(ptr::null_mut(), || {
match CString::new(native_core::version()) {
Ok(s) => s.into_raw(),
Err(_) => ptr::null_mut(),
}
})
}
/// Releases a string returned by this library. Null is a no-op.
///
/// # Safety
/// `ptr` must come from this library and must not be freed twice.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn native_core_free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
guard((), || {
// SAFETY: the caller guarantees `ptr` came from `CString::into_raw` in this library.
drop(unsafe { CString::from_raw(ptr) });
});
}
fn guard<T>(fallback: T, f: impl FnOnce() -> T + UnwindSafe) -> T {
panic::catch_unwind(f).unwrap_or(fallback)
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::CStr;
#[test]
fn version_roundtrips() {
let p = native_core_version();
assert!(!p.is_null());
// SAFETY: `p` is a C string from this library.
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
assert_eq!(s, native_core::version());
// SAFETY: `p` is freed exactly once.
unsafe { native_core_free_string(p) };
}
#[test]
fn free_null_is_noop() {
// SAFETY: null is allowed by the contract.
unsafe { native_core_free_string(ptr::null_mut()) };
}
}
@@ -0,0 +1,25 @@
use jni::EnvUnowned;
use jni::objects::{JClass, JIntArray, JObject};
use jni::sys::{jint, jlong};
use native_core::rotate::{Orientation, rotate};
use super::bitmap::{self, FORMAT_RGBA_8888};
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeImage_rotate<'caller>(
env: EnvUnowned<'caller>,
_class: JClass<'caller>,
bitmap: JObject<'caller>,
orientation: jint,
out_info: JIntArray<'caller>,
) -> jlong {
let orientation: Orientation = orientation.into();
bitmap::transform(
env,
bitmap,
out_info,
FORMAT_RGBA_8888,
|w, h| orientation.dimensions(w, h),
|src, stride, w, h, dst| rotate(src, stride, w, h, orientation, dst),
)
}
@@ -0,0 +1,79 @@
use std::{ptr, slice};
use libc::size_t;
#[cfg(target_os = "android")]
use jni::{
EnvUnowned,
errors::ThrowRuntimeExAndDefault,
objects::{JByteArray, JClass, JIntArray},
sys::jlong,
};
use native_core::thumbhash;
use super::guard;
/// Decodes a thumbhash to RGBA. The caller frees the returned buffer with free().
/// Returns null on failure.
///
/// # Safety
/// `hash` must be null or readable for `len` bytes, at most `isize::MAX`.
/// `width` and `height` must be null or valid writable pointers, outside `hash`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn native_core_thumbhash(
hash: *const u8,
len: size_t,
width: *mut i32,
height: *mut i32,
) -> *mut u8 {
guard(ptr::null_mut(), || {
if hash.is_null() || width.is_null() || height.is_null() {
return ptr::null_mut();
}
// SAFETY: the caller provides a buffer readable for len bytes.
let hash = unsafe { slice::from_raw_parts(hash, len) };
// SAFETY: calloc accepts any size.
let rgba = unsafe { libc::calloc(1, 32 * 32 * 4).cast::<[u8; 32 * 32 * 4]>() };
if rgba.is_null() {
return ptr::null_mut();
}
// SAFETY: calloc returned that many zeroed bytes that nothing else references.
let Some((w, h)) = thumbhash::decode(hash, unsafe { &mut *rgba }) else {
// SAFETY: the buffer has not been returned to the caller.
unsafe { libc::free(rgba.cast()) };
return ptr::null_mut();
};
// SAFETY: the outputs are writable.
unsafe {
*width = w as i32;
*height = h as i32;
}
rgba.cast()
})
}
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_images_ThumbHash_decode<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
hash: JByteArray<'caller>,
info: JIntArray<'caller>,
) -> jlong {
env.with_env(|env| -> jni::errors::Result<jlong> {
let hash = env.convert_byte_array(&hash)?;
let (mut width, mut height) = (0, 0);
// SAFETY: the hash bytes and both output pointers are valid for this call.
let rgba =
unsafe { native_core_thumbhash(hash.as_ptr(), hash.len(), &mut width, &mut height) };
if !rgba.is_null()
&& let Err(err) = info.set_region(env, 0, &[width, height, width * 4])
{
// SAFETY: this malloc buffer has not been transferred to Dart.
unsafe { libc::free(rgba.cast()) };
return Err(err);
}
Ok(rgba as jlong)
})
.resolve::<ThrowRuntimeExAndDefault>()
}
+8
View File
@@ -0,0 +1,8 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.rust]]
version = "1.99.1"
backend = "core:rust"
[tools.rust.options]
components = "clippy,rustfmt"
+18
View File
@@ -0,0 +1,18 @@
[tools]
rust = { version = "1.99.1", components = "rustfmt,clippy" } # keep in sync with crates/native_core_ffi/rust-toolchain.toml
[tasks.build]
description = "Build the native crates for the host"
run = "cargo build --workspace"
[tasks.test]
description = "Run the Rust tests"
run = "cargo test --workspace --locked"
[tasks.fmt]
description = "Check Rust formatting"
run = "cargo fmt --all --check"
[tasks.lint]
description = "Run clippy with warnings as errors"
run = "cargo clippy --workspace --all-targets --locked -- -D warnings"
+3
View File
@@ -0,0 +1,3 @@
.dart_tool/
build/
pubspec.lock
+94
View File
@@ -0,0 +1,94 @@
array-init
byteorder
bytes
cfg-if
combine
crossbeam-deque
crossbeam-epoch
crossbeam-utils
either
equivalent
hashbrown
jni
jni-sys
libc
log
memchr
num-traits
quick_cache
rayon
rayon-core
simd_cesu8
simdutf8
thiserror
Copyright (c) 2018 Carl Lerche
Copyright (c) 2014 Alex Crichton
Copyright (c) 2015 Markus Westerlind
Copyright (c) 2016 Prevoty, Inc. and jni-rs contributors
Copyright (c) 2015 The rust-jni-sys Developers
Copyright (c) The Rust Project Developers
Copyright (c) 2014 The Rust Project Developers
Copyright (c) 2015 Andrew Gallant
Copyright (c) 2017-2020 The array-init developers
Copyright (c) 2019 The Crossbeam Project Developers
Copyright (c) 2015
Copyright (c) 2010 The Rust Project Developers
Copyright (c) 2016--2023
Copyright (c) 2016 Amanieu d'Antras
Copyright (c) 2022 Arthur Silva
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
jxl
jxl_simd
jxl_transforms
moxcms
pic-scale
pxfm
Copyright (c) the JPEG XL Project Authors.
Copyright (c) Radzivon Bartoshyk.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+14
View File
@@ -0,0 +1,14 @@
include: package:flutter_lints/flutter.yaml
formatter:
page_width: 120
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
+9
View File
@@ -0,0 +1,9 @@
# The asset id must match assetName in hook/build.dart.
ffi-native:
asset-id: 'package:native_core/src/bindings.g.dart'
output: 'lib/src/bindings.g.dart'
headers:
entry-points:
- '../crates/native_core_ffi/include/native_core.h'
include-directives:
- '**/native_core.h'
+63
View File
@@ -0,0 +1,63 @@
import 'dart:io';
import 'package:code_assets/code_assets.dart';
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_rust/native_toolchain_rust.dart';
const _crate = '../crates/native_core_ffi';
// Cargo's dep-info only lists source files, so these have to rerun the hook too.
const _manifests = [
'../.cargo/config.toml',
'../Cargo.toml',
'../Cargo.lock',
'../crates/native_core/Cargo.toml',
'$_crate/Cargo.toml',
'$_crate/rust-toolchain.toml',
];
void main(List<String> args) async {
await build(args, (input, output) async {
if (!input.config.buildCodeAssets) return;
final code = input.config.code;
output.dependencies.addAll(_manifests.map(input.packageRoot.resolve));
final target = _crossTarget(code);
if (target != null) {
await _rustup(['target', 'add', '--toolchain', await _channel(input), target]);
}
await RustBuilder(assetName: 'src/bindings.g.dart', cratePath: _crate).run(input: input, output: output);
});
}
// mise sets RUSTUP_TOOLCHAIN, which makes rustup ignore rust-toolchain.toml and never install
// its targets, so add the one this build needs.
String? _crossTarget(CodeConfig code) => switch ((code.targetOS, code.targetArchitecture)) {
(OS.android, Architecture.arm) => 'armv7-linux-androideabi',
(OS.android, Architecture.arm64) => 'aarch64-linux-android',
(OS.android, Architecture.x64) => 'x86_64-linux-android',
(OS.iOS, Architecture.arm64) when code.iOS.targetSdk == IOSSdk.iPhoneSimulator => 'aarch64-apple-ios-sim',
(OS.iOS, Architecture.arm64) => 'aarch64-apple-ios',
(OS.iOS, Architecture.x64) => 'x86_64-apple-ios',
_ => null,
};
Future<String> _channel(BuildInput input) async {
final toml = await File.fromUri(input.packageRoot.resolve('$_crate/rust-toolchain.toml')).readAsString();
final match = RegExp(r'^channel = "([^"]+)"', multiLine: true).firstMatch(toml);
if (match == null) throw StateError('channel missing in rust-toolchain.toml');
return match.group(1)!;
}
Future<void> _rustup(List<String> args) async {
final home = Platform.environment[Platform.isWindows ? 'USERPROFILE' : 'HOME'];
ProcessResult result;
try {
result = await Process.run('rustup', args);
} on ProcessException {
if (home == null) rethrow;
result = await Process.run('$home/.cargo/bin/rustup', args);
}
if (result.exitCode != 0) {
throw ProcessException('rustup', args, '${result.stderr}', result.exitCode);
}
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:ffi/ffi.dart';
import 'src/bindings.g.dart';
String version() {
final ptr = native_core_version();
try {
return ptr.cast<Utf8>().toDartString();
} finally {
native_core_free_string(ptr);
}
}
+19
View File
@@ -0,0 +1,19 @@
name: native_core
publish_to: none
environment:
sdk: '>=3.13.0 <4.0.0'
dependencies:
code_assets: ^1.2.1
ffi: ^2.2.0
flutter:
sdk: flutter
hooks: ^2.2.0
native_toolchain_rust: ^1.0.6
dev_dependencies:
ffigen: ^21.0.0
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
@@ -0,0 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:native_core/native_core.dart';
void main() {
test('loads the core and returns its version', () {
expect(version(), isNotEmpty);
});
}