feat(mobile): jxl decoder (#32592)
This commit is contained in:
@@ -1,13 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.12)
|
||||
|
||||
set(CMAKE_C_STANDARD 17)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
project(native_buffer LANGUAGES C)
|
||||
|
||||
add_library(native_buffer SHARED
|
||||
src/main/cpp/native_buffer.c
|
||||
src/main/cpp/native_image.c
|
||||
)
|
||||
|
||||
target_link_libraries(native_buffer jnigraphics)
|
||||
@@ -79,11 +79,6 @@ android {
|
||||
}
|
||||
namespace 'app.alextran.immich'
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path "CMakeLists.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_allocate(
|
||||
JNIEnv *env, jclass clazz, jint size) {
|
||||
void *ptr = malloc(size);
|
||||
return (jlong) ptr;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_free(
|
||||
JNIEnv *env, jclass clazz, jlong address) {
|
||||
free((void *) address);
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_realloc(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint size) {
|
||||
void *ptr = realloc((void *) address, size);
|
||||
return (jlong) ptr;
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_wrap(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
||||
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_copy(
|
||||
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
|
||||
void *src = (*env)->GetDirectBufferAddress(env, buffer);
|
||||
if (src != NULL) {
|
||||
memcpy((void *) destAddress, (char *) src + offset, length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JNI global reference to the given object and returns its address.
|
||||
* The caller is responsible for deleting the global reference when it's no longer needed.
|
||||
*/
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
|
||||
if (obj == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
jobject globalRef = (*env)->NewGlobalRef(env, obj);
|
||||
return (jlong) globalRef;
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <android/bitmap.h>
|
||||
|
||||
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
|
||||
#define TILE 32
|
||||
|
||||
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
|
||||
enum {
|
||||
ORIENTATION_FLIP_HORIZONTAL = 2,
|
||||
ORIENTATION_ROTATE_180 = 3,
|
||||
ORIENTATION_FLIP_VERTICAL = 4,
|
||||
ORIENTATION_TRANSPOSE = 5,
|
||||
ORIENTATION_ROTATE_90 = 6,
|
||||
ORIENTATION_TRANSVERSE = 7,
|
||||
ORIENTATION_ROTATE_270 = 8,
|
||||
};
|
||||
|
||||
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
|
||||
static int swaps_dims(int o) {
|
||||
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
|
||||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
|
||||
}
|
||||
|
||||
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
|
||||
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
|
||||
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
|
||||
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
|
||||
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
|
||||
switch (o) {
|
||||
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
|
||||
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
|
||||
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
|
||||
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
|
||||
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
|
||||
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
|
||||
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
|
||||
default: *base = 0; *stepX = 1; *stepY = dw; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
|
||||
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
|
||||
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
|
||||
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
|
||||
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
|
||||
for (int ty = 0; ty < sh; ty += TILE) {
|
||||
int yEnd = ty + TILE < sh ? ty + TILE : sh;
|
||||
for (int tx = 0; tx < sw; tx += TILE) {
|
||||
int xEnd = tx + TILE < sw ? tx + TILE : sw;
|
||||
for (int sy = ty; sy < yEnd; sy++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
|
||||
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
|
||||
for (int sx = tx; sx < xEnd; sx++) {
|
||||
dst[idx] = srcRow[sx];
|
||||
idx += stepX;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
|
||||
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_rotate(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sw = (int) info.width;
|
||||
int sh = (int) info.height;
|
||||
int dw = swaps_dims(orientation) ? sh : sw;
|
||||
int dh = swaps_dims(orientation) ? sw : sh;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int64_t base, stepX, stepY;
|
||||
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
|
||||
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {dw, dh, dw * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
|
||||
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
|
||||
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
|
||||
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
|
||||
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
|
||||
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
|
||||
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
|
||||
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
|
||||
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
|
||||
for (int y = 0; y < h; y++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
|
||||
uint32_t *dstRow = dst + (size_t) y * w;
|
||||
for (int x = 0; x < w; x++) {
|
||||
uint32_t px = srcRow[x];
|
||||
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t a = ((px >> 30) & 0x3) * 85u;
|
||||
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
|
||||
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
|
||||
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_convert1010102(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w = (int) info.width;
|
||||
int h = (int) info.height;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {w, h, w * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024
|
||||
|
||||
object NativeBuffer {
|
||||
init {
|
||||
System.loadLibrary("native_buffer")
|
||||
System.loadLibrary("native_core_ffi")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object NativeImage {
|
||||
init {
|
||||
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
|
||||
System.loadLibrary("native_buffer")
|
||||
System.loadLibrary("native_core_ffi")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,4 +26,24 @@ object NativeImage {
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
||||
|
||||
/**
|
||||
* Like [convert1010102], but decodes the JPEG XL image of [length] bytes at [address], stopped by
|
||||
* [cancel], downscaled to cover [targetWidth] x [targetHeight] unless either is 0 and to fit
|
||||
* within [maxPixelSize].
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun decodeJxl(
|
||||
address: Long,
|
||||
length: Int,
|
||||
targetWidth: Int,
|
||||
targetHeight: Int,
|
||||
maxPixelSize: Int,
|
||||
cancelled: ByteBuffer,
|
||||
outInfo: IntArray
|
||||
): Long
|
||||
|
||||
/** Stops the [decodeJxl] given this one-byte direct buffer. */
|
||||
@JvmStatic
|
||||
external fun cancel(cancelled: ByteBuffer)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.exifinterface.media.ExifInterface
|
||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||
import app.alextran.immich.NativeBuffer
|
||||
import app.alextran.immich.NativeByteBuffer
|
||||
import app.alextran.immich.NativeImage
|
||||
import app.alextran.immich.core.HttpClientManager
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.Cache
|
||||
@@ -56,10 +57,9 @@ private val RAW_MIME_TYPES = setOf(
|
||||
"image/x-sony-sr2", "image/x-sony-srf", "image/x3f",
|
||||
)
|
||||
|
||||
private fun isRawMime(contentType: String?): Boolean {
|
||||
val mime = contentType?.substringBefore(';')?.trim()?.lowercase() ?: return false
|
||||
return mime in RAW_MIME_TYPES
|
||||
}
|
||||
private fun mimeType(contentType: String?) = contentType?.substringBefore(';')?.trim()?.lowercase()
|
||||
|
||||
private fun isRawMime(contentType: String?) = mimeType(contentType) in RAW_MIME_TYPES
|
||||
|
||||
class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
|
||||
@@ -97,6 +97,42 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
return@fetch callback(CANCELLED)
|
||||
}
|
||||
|
||||
// Neither Android nor Flutter can decode JPEG XL, so this applies to encoded requests too.
|
||||
if (mimeType(contentType) == "image/jxl") {
|
||||
decodeExecutor.execute {
|
||||
val info = IntArray(3)
|
||||
// The fetch is done, so cancelling now stops the decode instead.
|
||||
val cancelled = ByteBuffer.allocateDirect(1)
|
||||
signal.setOnCancelListener { NativeImage.cancel(cancelled) }
|
||||
val pointer = if (signal.isCanceled) 0L else NativeImage.decodeJxl(
|
||||
buffer.pointer,
|
||||
buffer.offset,
|
||||
width?.toInt() ?: 0,
|
||||
height?.toInt() ?: 0,
|
||||
MAX_PIXEL_SIZE,
|
||||
cancelled,
|
||||
info
|
||||
)
|
||||
buffer.free()
|
||||
requestMap.remove(requestId)
|
||||
callback(
|
||||
when {
|
||||
pointer != 0L -> Result.success(
|
||||
mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
)
|
||||
)
|
||||
signal.isCanceled -> CANCELLED
|
||||
else -> Result.failure(IOException("Failed to decode JPEG XL image"))
|
||||
}
|
||||
)
|
||||
}
|
||||
return@fetch
|
||||
}
|
||||
|
||||
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
|
||||
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
|
||||
// dedicated pool - the fetch callback threads are shared with video streaming. On any
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:native_core/native_core.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('native core loads on device', () {
|
||||
expect(version(), isNotEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
enum NativeCore {
|
||||
private static let library = dlopen("@rpath/native_core_ffi.framework/native_core_ffi", RTLD_NOW)
|
||||
|
||||
private static func symbol<T>(_ name: String, as type: T.Type) -> T? {
|
||||
guard let library, let symbol = dlsym(library, name) else { return nil }
|
||||
return unsafeBitCast(symbol, to: type)
|
||||
}
|
||||
|
||||
private static let coreThumbhash = symbol("native_core_thumbhash", as: NativeCoreThumbhashFn.self)
|
||||
private static let coreJxlDecode = symbol("native_core_jxl_decode", as: NativeCoreJxlDecodeFn.self)
|
||||
private static let coreCancel = symbol("native_core_cancel", as: NativeCoreCancelFn.self)
|
||||
|
||||
static func thumbhash(_ hash: Data) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
||||
guard let coreThumbhash else { return nil }
|
||||
var width: Int32 = 0
|
||||
var height: Int32 = 0
|
||||
return hash.withUnsafeBytes { bytes in
|
||||
guard let pointer = coreThumbhash(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, &width, &height)
|
||||
else { return nil }
|
||||
return (Int(width), Int(height), UnsafeMutableRawPointer(pointer))
|
||||
}
|
||||
}
|
||||
|
||||
static func decodeJxl(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
||||
guard let coreJxlDecode else { return nil }
|
||||
var info = NativeCoreImageInfo()
|
||||
return data.withUnsafeBytes { bytes in
|
||||
guard let pointer = coreJxlDecode(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
|
||||
else { return nil }
|
||||
return (Int(info.width), Int(info.height), UnsafeMutableRawPointer(pointer))
|
||||
}
|
||||
}
|
||||
|
||||
static func cancel(_ flag: UnsafeMutablePointer<Bool>) {
|
||||
coreCancel?(flag)
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,6 @@ enum ImageProcessing {
|
||||
return q
|
||||
}()
|
||||
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
|
||||
// Impeller shrinks each side to the GPU texture limit on its own, which squishes long images.
|
||||
static let maxPixelSize = 16384
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
requestOptions.version = .current
|
||||
return requestOptions
|
||||
}()
|
||||
private static let maxPixelSize: CGFloat = 16384
|
||||
|
||||
private static let registry = RequestRegistry<ImageRequest>()
|
||||
|
||||
@@ -111,7 +110,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
|
||||
let isOriginal = !(width > 0 && height > 0)
|
||||
let targetSize = isOriginal
|
||||
? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize)
|
||||
? CGSize(width: ImageProcessing.maxPixelSize, height: ImageProcessing.maxPixelSize)
|
||||
: CGSize(width: Double(width), height: Double(height))
|
||||
let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill
|
||||
var image: UIImage?
|
||||
@@ -136,7 +135,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
}
|
||||
|
||||
// .fast can return larger than the target, so retry with .exact to guarantee the bound.
|
||||
if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) {
|
||||
if max(cgImage.width, cgImage.height) > ImageProcessing.maxPixelSize {
|
||||
let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions
|
||||
exactOptions.resizeMode = .exact
|
||||
image = nil
|
||||
|
||||
@@ -7,15 +7,23 @@ import Photos
|
||||
final class RemoteImageRequest: ImageRequest {
|
||||
var task: URLSessionDataTask?
|
||||
let id: Int64
|
||||
/// Lets cancellation stop a native decode partway through.
|
||||
let decodeCancelled = UnsafeMutablePointer<Bool>.allocate(capacity: 1)
|
||||
|
||||
init(id: Int64, completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
self.id = id
|
||||
super.init(completion: completion)
|
||||
decodeCancelled.initialize(to: false)
|
||||
}
|
||||
|
||||
deinit {
|
||||
decodeCancelled.deallocate()
|
||||
}
|
||||
|
||||
override func cancel() {
|
||||
super.cancel()
|
||||
task?.cancel()
|
||||
NativeCore.cancel(decodeCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +73,38 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
||||
}
|
||||
|
||||
// Flutter can't decode JPEG XL and older iOS versions can't either, so this applies to encoded requests too.
|
||||
if response?.mimeType == "image/jxl" {
|
||||
ImageProcessing.queue.addOperation {
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
guard let (width, height, pointer) = NativeCore.decodeJxl(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else {
|
||||
registry.remove(requestId: request.id)
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode JPEG XL image", details: nil)))
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
free(pointer)
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
registry.remove(requestId: request.id)
|
||||
request.completion(
|
||||
.success([
|
||||
"pointer": Int64(Int(bitPattern: pointer)),
|
||||
"width": Int64(width),
|
||||
"height": Int64(height),
|
||||
"rowBytes": Int64(width * 4),
|
||||
]))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if encoded {
|
||||
let length = data.count
|
||||
let pointer = malloc(length)!
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
#include "../../../native/crates/native_core_ffi/include/native_core.h"
|
||||
|
||||
// The function pointer type comes from the declaration, so nothing is retyped here.
|
||||
typedef __typeof__(&native_core_thumbhash) NativeCoreThumbhashFn;
|
||||
typedef __typeof__(&native_core_jxl_decode) NativeCoreJxlDecodeFn;
|
||||
typedef __typeof__(&native_core_cancel) NativeCoreCancelFn;
|
||||
|
||||
@@ -79,8 +79,7 @@ abstract class ImageRequest {
|
||||
return (codec, descriptor);
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async {
|
||||
final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize);
|
||||
Future<ui.FrameInfo?> _firstFrame((ui.Codec, ui.ImageDescriptor)? result) async {
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -116,7 +115,12 @@ abstract class ImageRequest {
|
||||
return ((width * scale).ceil(), (height * scale).ceil());
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
|
||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromDecodedPlatformImage(
|
||||
int address,
|
||||
int width,
|
||||
int height,
|
||||
int rowBytes,
|
||||
) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
malloc.free(pointer);
|
||||
@@ -152,14 +156,6 @@ abstract class ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
final frame = await codec.getNextFrame();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
if (_isCancelled) {
|
||||
frame.image.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
return frame;
|
||||
return (codec, descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ class LocalImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
||||
final frame = await _firstFrame(
|
||||
await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!),
|
||||
);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,22 +21,7 @@ class RemoteImageRequest extends ImageRequest {
|
||||
width: decodeSize?.width.ceil(),
|
||||
height: decodeSize?.height.ceil(),
|
||||
);
|
||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
||||
final frame = switch (info) {
|
||||
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(
|
||||
pointer,
|
||||
length,
|
||||
decodeSize: decodeSize,
|
||||
),
|
||||
{
|
||||
'pointer': final int pointer,
|
||||
'width': final int width,
|
||||
'height': final int height,
|
||||
'rowBytes': final int rowBytes,
|
||||
} =>
|
||||
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
|
||||
_ => null,
|
||||
};
|
||||
final frame = await _firstFrame(await _codecFromResponse(info, decodeSize: decodeSize));
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
@@ -53,14 +38,29 @@ class RemoteImageRequest extends ImageRequest {
|
||||
width: null,
|
||||
height: null,
|
||||
);
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
||||
return codec;
|
||||
final result = await _codecFromResponse(info);
|
||||
return result?.$1;
|
||||
}
|
||||
|
||||
// Android falls back to encoded data if native decoding fails, and JPEG XL arrives decoded even when the encoded image
|
||||
// is requested, so check for both shapes of the response.
|
||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromResponse(Map<String, int>? info, {ui.Size? decodeSize}) =>
|
||||
switch (info) {
|
||||
{'pointer': final int pointer, 'length': final int length} => _codecFromEncodedPlatformImage(
|
||||
pointer,
|
||||
length,
|
||||
decodeSize: decodeSize,
|
||||
),
|
||||
{
|
||||
'pointer': final int pointer,
|
||||
'width': final int width,
|
||||
'height': final int height,
|
||||
'rowBytes': final int rowBytes,
|
||||
} =>
|
||||
_codecFromDecodedPlatformImage(pointer, width, height, rowBytes),
|
||||
_ => Future.value(),
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> _onCancelled() {
|
||||
return remoteImageApi.cancelRequest(requestId);
|
||||
|
||||
@@ -12,7 +12,9 @@ class ThumbhashImageRequest extends ImageRequest {
|
||||
}
|
||||
|
||||
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash);
|
||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
||||
final frame = await _firstFrame(
|
||||
await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!),
|
||||
);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.3.5-local.8+3030501
|
||||
version: 3.3.5-local.9+3030502
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
@@ -39,6 +39,8 @@ dependencies:
|
||||
hooks_riverpod: ^2.6.1
|
||||
http: ^1.6.0
|
||||
image_picker: ^1.2.3
|
||||
native_core:
|
||||
path: ../native/native_core
|
||||
immich_ui:
|
||||
path: './packages/ui'
|
||||
intl: ^0.20.2
|
||||
|
||||
@@ -76,6 +76,24 @@ void main() {
|
||||
expect(args[4], isNull);
|
||||
});
|
||||
|
||||
test('accepts a decoded image for an animation request', () async {
|
||||
final pointer = malloc<Uint8>(8)..asTypedList(8).setAll(0, [255, 0, 0, 255, 0, 0, 255, 255]);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, (
|
||||
message,
|
||||
) async {
|
||||
return <Object?>[
|
||||
<Object?, Object?>{'pointer': pointer.address, 'width': 2, 'height': 1, 'rowBytes': 8},
|
||||
];
|
||||
});
|
||||
|
||||
final codec = (await RemoteImageRequest(uri: 'https://example.test/animation').loadCodec())!;
|
||||
final image = (await codec.getNextFrame()).image;
|
||||
|
||||
expect((image.width, image.height), (2, 1));
|
||||
image.dispose();
|
||||
codec.dispose();
|
||||
});
|
||||
|
||||
test('keeps portrait cover quality in a wide tile', () async {
|
||||
final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size(963, 642));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user