refactor(mobile): move the native core decoders into a codecs crate (#32765)

* refactor(mobile): move the native core decoders into a codecs crate

* make Format private

* rename the decode export and options for what they hold

* drop the unused ios and server groups and rename the target params

(cherry picked from commit 11bf875313782b8a9e10269bb6f85226b7bdcd72)
This commit is contained in:
Santo Shakil
2026-10-28 20:40:53 +03:00
committed by Codex
parent f418967d55
commit 3127dbec71
35 changed files with 160 additions and 61 deletions
@@ -28,12 +28,12 @@ object NativeImage {
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
/**
* Like [convert1010102], but decodes the JPEG XL image of [length] bytes at [address], stopped by
* Like [convert1010102], but decodes the image of [length] bytes at [address], stopped by
* [cancel], downscaled to cover [targetWidth] x [targetHeight] unless either is 0 and to fit
* within [maxPixelSize].
*/
@JvmStatic
external fun decodeJxl(
external fun decode(
address: Long,
length: Int,
targetWidth: Int,
@@ -43,7 +43,7 @@ object NativeImage {
outInfo: IntArray
): Long
/** Stops the [decodeJxl] given this one-byte direct buffer. */
/** Stops the [decode] given this one-byte direct buffer. */
@JvmStatic
external fun cancel(cancelled: ByteBuffer)
}
@@ -106,7 +106,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
// The fetch is done, so cancelling now stops the decode instead.
val cancelled = ByteBuffer.allocateDirect(1)
signal.setOnCancelListener { NativeImage.cancel(cancelled) }
val pointer = if (signal.isCanceled) 0L else NativeImage.decodeJxl(
val pointer = if (signal.isCanceled) 0L else NativeImage.decode(
buffer.pointer,
buffer.offset,
width?.toInt() ?: 0,
+4 -4
View File
@@ -9,7 +9,7 @@ enum NativeCore {
}
private static let coreThumbhash = symbol("native_core_thumbhash", as: NativeCoreThumbhashFn.self)
private static let coreJxlDecode = symbol("native_core_jxl_decode", as: NativeCoreJxlDecodeFn.self)
private static let coreDecodeImage = symbol("native_core_decode_image", as: NativeCoreDecodeImageFn.self)
private static let coreCancel = symbol("native_core_cancel", as: NativeCoreCancelFn.self)
static func thumbhash(_ hash: Data) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
@@ -23,11 +23,11 @@ enum NativeCore {
}
}
static func decodeJxl(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
guard let coreJxlDecode else { return nil }
static func decodeImage(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
guard let coreDecodeImage else { return nil }
var info = NativeCoreImageInfo()
return data.withUnsafeBytes { bytes in
guard let pointer = coreJxlDecode(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
guard let pointer = coreDecodeImage(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
else { return nil }
return (Int(info.width), Int(info.height), UnsafeMutableRawPointer(pointer))
}
@@ -80,7 +80,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
return request.completion(ImageProcessing.cancelledResult)
}
guard let (width, height, pointer) = NativeCore.decodeJxl(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else {
guard let (width, height, pointer) = NativeCore.decodeImage(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else {
registry.remove(requestId: request.id)
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
+1 -1
View File
@@ -3,5 +3,5 @@
// The function pointer type comes from the declaration, so nothing is retyped here.
typedef __typeof__(&native_core_thumbhash) NativeCoreThumbhashFn;
typedef __typeof__(&native_core_jxl_decode) NativeCoreJxlDecodeFn;
typedef __typeof__(&native_core_decode_image) NativeCoreDecodeImageFn;
typedef __typeof__(&native_core_cancel) NativeCoreCancelFn;
+13 -6
View File
@@ -56,6 +56,18 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "codecs"
version = "0.1.0"
dependencies = [
"jxl",
"libc",
"moxcms",
"pic-scale",
"quick_cache",
"rayon",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -287,12 +299,7 @@ dependencies = [
name = "native_core"
version = "0.1.0"
dependencies = [
"jxl",
"libc",
"moxcms",
"pic-scale",
"quick_cache",
"rayon",
"codecs",
]
[[package]]
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "3"
members = ["crates/native_core", "crates/native_core_ffi"]
members = ["crates/codecs", "crates/native_core", "crates/native_core_ffi"]
[workspace.package]
version = "0.1.0"
+3 -2
View File
@@ -5,8 +5,9 @@ Rust code that ships inside the mobile app. Flutter compiles it from source thro
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`
- `crates/codecs`: the image decoders, a feature each, behind one `decode`
- `crates/native_core`: the shared logic, a feature per part and a group per target
- `crates/native_core_ffi`: the C ABI on the `flutter` group, 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)
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "codecs"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["jxl"]
jxl = ["dep:jxl", "dep:rayon"]
[dependencies]
jxl = { workspace = true, optional = true }
libc = { workspace = true }
moxcms = { workspace = true }
pic-scale = { workspace = true }
quick_cache = { workspace = true }
rayon = { workspace = true, optional = true }
[lints]
workspace = true
@@ -70,7 +70,7 @@ impl JxlParallelRunner for Cancellable<'_> {
}
impl<'a> Jxl<'a> {
pub fn open(mut input: &'a [u8], target: Target) -> Option<Self> {
pub fn open(mut input: &'a [u8], color_space: Target) -> Option<Self> {
let mut options = JxlDecoderOptions::default();
// Colour transforms need straight alpha; premultiplying happens last.
options.alpha_mode = AlphaMode::Unpremultiplied;
@@ -85,7 +85,7 @@ impl<'a> Jxl<'a> {
.ok()?;
let profile = decoder.output_color_profile()?;
if let Some(cicp) = cicp(profile)
&& (target == Target::Source || color::is_srgb(cicp))
&& (color_space == Target::Source || color::is_srgb(cicp))
{
if color::is_hdr(cicp) {
decoder
@@ -121,11 +121,11 @@ impl<'a> Jxl<'a> {
})
}
/// Decodes the first frame unless cancelled, downscaled to cover `target` pixels and to fit
/// within `max_pixel_size`.
/// Decodes the first frame unless cancelled, downscaled to cover `target_size` pixels and to
/// fit within `max_pixel_size`.
pub fn decode(
mut self,
target: Option<(usize, usize)>,
target_size: Option<(usize, usize)>,
max_pixel_size: usize,
cancelled: &AtomicBool,
) -> Option<Image> {
@@ -139,7 +139,7 @@ impl<'a> Jxl<'a> {
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 size = resize::cover((w, h), target_size, 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)?;
+49
View File
@@ -0,0 +1,49 @@
use std::sync::atomic::AtomicBool;
use color::Target;
use image::Image;
pub mod color;
pub mod image;
#[cfg(feature = "jxl")]
pub mod jxl;
mod resize;
enum Format {
#[cfg(feature = "jxl")]
Jxl,
}
pub struct Options {
pub color_space: Target,
/// Downscales to the smallest size that still fills this.
pub target_size: Option<(usize, usize)>,
/// Downscales to fit within this many pixels per side.
pub max_pixel_size: usize,
}
fn probe(data: &[u8]) -> Option<Format> {
#[cfg(feature = "jxl")]
if matches!(
::jxl::api::check_signature(data),
::jxl::api::JxlSignature::Codestream | ::jxl::api::JxlSignature::Container
) {
return Some(Format::Jxl);
}
None
}
/// Decodes the first frame of `data` unless cancelled.
pub fn decode(data: &[u8], options: &Options, cancelled: &AtomicBool) -> Option<Image> {
match probe(data)? {
#[cfg(feature = "jxl")]
Format::Jxl => jxl::Jxl::open(data, options.color_space)?.decode(
options.target_size,
options.max_pixel_size,
cancelled,
),
}
}
@@ -47,16 +47,16 @@ impl Lanczos for u16 {
}
}
/// The size of `size` scaled down to cover `target` and to fit within `max_pixel_size`, if that
/// is smaller.
/// The size of `size` scaled down to cover `target_size` and to fit within `max_pixel_size`, if
/// that is smaller.
pub(crate) fn cover(
(w, h): (usize, usize),
target: Option<(usize, usize)>,
target_size: 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 {
let (num, den) = match target_size {
Some((tw, th)) => {
let cover = if tw * h >= th * w { (tw, w) } else { (th, h) };
if cover.0 * fit.1 < fit.0 * cover.1 {
@@ -1,11 +1,19 @@
#![cfg(feature = "jxl")]
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;
use codecs::Options;
use codecs::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
use codecs::image::{Image, Pixels, Samples};
use codecs::jxl::Jxl;
fn decode(data: &[u8], target: Target) -> Option<Image> {
Jxl::open(data, target)?.decode(None, 16384, &AtomicBool::new(false))
fn decode(data: &[u8], color_space: Target) -> Option<Image> {
let options = Options {
color_space,
target_size: None,
max_pixel_size: 16384,
};
codecs::decode(data, &options, &AtomicBool::new(false))
}
fn decode_srgb(data: &[u8]) -> Option<Pixels<u8>> {
+9 -6
View File
@@ -4,13 +4,16 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[features]
convert = []
rotate = []
thumbhash = []
jxl = ["codecs/jxl"]
# One group per target.
flutter = ["convert", "rotate", "thumbhash", "jxl"]
[dependencies]
jxl = { workspace = true }
libc = { workspace = true }
moxcms = { workspace = true }
pic-scale = { workspace = true }
quick_cache = { workspace = true }
rayon = { workspace = true }
codecs = { path = "../codecs", default-features = false, optional = true }
[lints]
workspace = true
+5 -4
View File
@@ -1,9 +1,10 @@
pub mod color;
#[cfg(feature = "codecs")]
pub use codecs;
#[cfg(feature = "convert")]
pub mod convert;
pub mod image;
pub mod jxl;
mod resize;
#[cfg(feature = "rotate")]
pub mod rotate;
#[cfg(feature = "thumbhash")]
pub mod thumbhash;
pub fn version() -> &'static str {
@@ -1,3 +1,5 @@
#![cfg(feature = "convert")]
use native_core::convert::convert_1010102;
#[test]
@@ -1,3 +1,5 @@
#![cfg(feature = "rotate")]
use native_core::rotate::{Orientation, rotate};
#[test]
@@ -1,3 +1,5 @@
#![cfg(feature = "thumbhash")]
use native_core::thumbhash::decode;
// Expected pixels come from the old Swift decoder patched to round like the Java did (it truncated),
+1 -1
View File
@@ -10,7 +10,7 @@ name = "native_core_ffi"
crate-type = ["cdylib", "staticlib"]
[dependencies]
native_core = { path = "../native_core" }
native_core = { path = "../native_core", features = ["flutter"] }
libc = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
+1 -1
View File
@@ -3,7 +3,7 @@ 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/decode.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();
@@ -10,9 +10,9 @@ use jni::{
objects::{JByteBuffer, JClass, JIntArray},
sys::{jint, jlong},
};
use native_core::color::Target;
use native_core::image::{Image, Samples};
use native_core::jxl::Jxl;
use native_core::codecs::color::Target;
use native_core::codecs::image::{Image, Samples};
use native_core::codecs::{self, Options};
use super::guard;
@@ -30,7 +30,7 @@ pub struct NativeCoreImageInfo {
pub transfer: u8,
}
/// Decodes a JPEG XL image to premultiplied RGBA in sRGB, or with `keep_space`, in the image's own
/// Decodes an 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
@@ -42,7 +42,7 @@ pub struct NativeCoreImageInfo {
/// `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(
pub unsafe extern "C" fn native_core_decode_image(
data: *const u8,
len: size_t,
keep_space: bool,
@@ -60,16 +60,19 @@ pub unsafe extern "C" fn native_core_jxl_decode(
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 {
let color_space = if keep_space {
Target::Source
} else {
Target::Srgb
};
let cover = (target_width > 0 && target_height > 0)
let target_size = (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 {
let options = Options {
color_space,
target_size,
max_pixel_size: max_pixel_size as usize,
};
let Some(Image { cicp, samples }) = codecs::decode(input, &options, cancelled) else {
return ptr::null_mut();
};
let (width, height, bit_depth, pixels) = match samples {
@@ -93,7 +96,7 @@ pub unsafe extern "C" fn native_core_jxl_decode(
/// Asks the decode given `flag` to stop early.
///
/// # Safety
/// `flag` must be null or valid and otherwise only read by `native_core_jxl_decode`.
/// `flag` must be null or valid and otherwise only read by `native_core_decode_image`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
if !flag.is_null() {
@@ -104,7 +107,7 @@ pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeImage_decodeJxl<'caller>(
pub extern "system" fn Java_app_alextran_immich_NativeImage_decode<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
data: jlong,
@@ -121,7 +124,7 @@ pub extern "system" fn Java_app_alextran_immich_NativeImage_decodeJxl<'caller>(
// 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(
native_core_decode_image(
data as *const u8,
length as size_t,
false,
@@ -154,7 +157,7 @@ pub extern "system" fn Java_app_alextran_immich_NativeImage_cancel<'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`.
// SAFETY: the buffer is the live one-byte flag Kotlin passed to `decode`.
unsafe { native_core_cancel(flag) };
Ok(())
})
+1 -1
View File
@@ -9,7 +9,7 @@ mod android;
mod bitmap;
#[cfg(target_os = "android")]
mod convert;
mod jxl;
mod decode;
#[cfg(target_os = "android")]
mod rotate;
mod thumbhash;
+1
View File
@@ -11,6 +11,7 @@ const _manifests = [
'../.cargo/config.toml',
'../Cargo.toml',
'../Cargo.lock',
'../crates/codecs/Cargo.toml',
'../crates/native_core/Cargo.toml',
'$_crate/Cargo.toml',
'$_crate/rust-toolchain.toml',