chore(mobile): one thumbhash decoder in the native core (#31367)

* chore(mobile): rust native core

* chore(mobile): move the android byte buffer into the native core

* chore(mobile): move the raw photo rotation into the native core

* move the rotation into immich_core, the jni side only wraps it

* use the pointer loop in rotate again, the bounds checked writes were slower on a pixel 5

* chore(mobile): move the 10 bit conversion into the native core

* move the 10 bit conversion into immich_core, neon for 32 bit arm

* chore(mobile): one thumbhash decoder in the native core

* decode the thumbhash straight into the returned buffer, its jni entry sits next to the c export

* chore(mobile): move the 10 bit conversion into the native core

* move the 10 bit conversion into immich_core, neon for 32 bit arm
This commit is contained in:
Santo Shakil
2026-10-08 12:02:39 +06:00
committed by GitHub
parent b08a8fb56c
commit c0d1dc923e
18 changed files with 326 additions and 433 deletions
@@ -117,12 +117,16 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
threadPool.execute {
try {
val bytes = Base64.getDecoder().decode(thumbhash)
val image = ThumbHash.thumbHashToRGBA(bytes)
val info = IntArray(3)
val pointer = ThumbHash.decode(bytes, info)
if (pointer == 0L) {
throw IllegalArgumentException("invalid thumbhash")
}
val res = mapOf(
"pointer" to image.pointer,
"width" to image.width.toLong(),
"height" to image.height.toLong(),
"rowBytes" to (image.width * 4).toLong()
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
@@ -1,166 +0,0 @@
package app.alextran.immich.images;
// 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.
import java.nio.ByteBuffer;
import app.alextran.immich.NativeBuffer;
// modified to use native allocations
public final class ThumbHash {
/**
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The width, height, and pixels of the rendered placeholder image.
*/
public static Image thumbHashToRGBA(byte[] hash) {
// Read the constants
int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
float l_dc = (float) (header24 & 63) / 63.0f;
float p_dc = (float) ((header24 >> 6) & 63) / 31.5f - 1.0f;
float q_dc = (float) ((header24 >> 12) & 63) / 31.5f - 1.0f;
float l_scale = (float) ((header24 >> 18) & 31) / 31.0f;
boolean hasAlpha = (header24 >> 23) != 0;
float p_scale = (float) ((header16 >> 3) & 63) / 63.0f;
float q_scale = (float) ((header16 >> 9) & 63) / 63.0f;
boolean isLandscape = (header16 >> 15) != 0;
int lx = Math.max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
int ly = Math.max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
float a_dc = hasAlpha ? (float) (hash[5] & 15) / 15.0f : 1.0f;
float a_scale = (float) ((hash[5] >> 4) & 15) / 15.0f;
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
int ac_start = hasAlpha ? 6 : 5;
int ac_index = 0;
Channel l_channel = new Channel(lx, ly);
Channel p_channel = new Channel(3, 3);
Channel q_channel = new Channel(3, 3);
Channel a_channel = null;
ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale);
ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f);
ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f);
if (hasAlpha) {
a_channel = new Channel(5, 5);
a_channel.decode(hash, ac_start, ac_index, a_scale);
}
float[] l_ac = l_channel.ac;
float[] p_ac = p_channel.ac;
float[] q_ac = q_channel.ac;
float[] a_ac = hasAlpha ? a_channel.ac : null;
// Decode using the DCT into RGB
float ratio = thumbHashToApproximateAspectRatio(hash);
int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
int size = w * h * 4;
long pointer = NativeBuffer.allocate(size);
ByteBuffer rgba = NativeBuffer.wrap(pointer, size);
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
float[] fx = new float[cx_stop];
float[] fy = new float[cy_stop];
for (int y = 0, i = 0; y < h; y++) {
for (int x = 0; x < w; x++, i += 4) {
float l = l_dc, p = p_dc, q = q_dc, a = a_dc;
// Precompute the coefficients
for (int cx = 0; cx < cx_stop; cx++)
fx[cx] = (float) Math.cos(Math.PI / w * (x + 0.5f) * cx);
for (int cy = 0; cy < cy_stop; cy++)
fy[cy] = (float) Math.cos(Math.PI / h * (y + 0.5f) * cy);
// Decode L
for (int cy = 0, j = 0; cy < ly; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++)
l += l_ac[j] * fx[cx] * fy2;
}
// Decode P and Q
for (int cy = 0, j = 0; cy < 3; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; cx++, j++) {
float f = fx[cx] * fy2;
p += p_ac[j] * f;
q += q_ac[j] * f;
}
}
// Decode A
if (hasAlpha)
for (int cy = 0, j = 0; cy < 5; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; cx++, j++)
a += a_ac[j] * fx[cx] * fy2;
}
// Convert to RGB
float b = l - 2.0f / 3.0f * p;
float r = (3.0f * l - b + q) / 2.0f;
float g = r - q;
rgba.put(i, (byte) Math.max(0, Math.round(255.0f * Math.min(1, r))));
rgba.put(i + 1, (byte) Math.max(0, Math.round(255.0f * Math.min(1, g))));
rgba.put(i + 2, (byte) Math.max(0, Math.round(255.0f * Math.min(1, b))));
rgba.put(i + 3, (byte) Math.max(0, Math.round(255.0f * Math.min(1, a))));
}
}
return new Image(w, h, pointer);
}
/**
* Extracts the approximate aspect ratio of the original image.
*
* @param hash The bytes of the ThumbHash.
* @return The approximate aspect ratio (i.e. width / height).
*/
public static float thumbHashToApproximateAspectRatio(byte[] hash) {
byte header = hash[3];
boolean hasAlpha = (hash[2] & 0x80) != 0;
boolean isLandscape = (hash[4] & 0x80) != 0;
int lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
int ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
return (float) lx / (float) ly;
}
public static final class Image {
public int width;
public int height;
public long pointer;
public Image(int width, int height, long pointer) {
this.width = width;
this.height = height;
this.pointer = pointer;
}
}
private static final class Channel {
int nx;
int ny;
float[] ac;
Channel(int nx, int ny) {
this.nx = nx;
this.ny = ny;
int n = 0;
for (int cy = 0; cy < ny; cy++)
for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
n++;
ac = new float[n];
}
int decode(byte[] hash, int start, int index, float scale) {
for (int i = 0; i < ac.length; i++) {
int data = hash[start + (index >> 1)] >> ((index & 1) << 2);
ac[i] = ((float) (data & 15) / 7.5f - 1.0f) * scale;
index++;
}
return index;
}
}
}
@@ -0,0 +1,10 @@
package app.alextran.immich.images
object ThumbHash {
init {
System.loadLibrary("immich_core_ffi")
}
@JvmStatic
external fun decode(hash: ByteArray, info: IntArray): Long
}
@@ -36,7 +36,6 @@
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; };
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; };
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */; };
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
@@ -131,7 +130,6 @@
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = "<group>"; };
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -357,7 +355,6 @@
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */,
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
);
path = Images;
sourceTree = "<group>";
@@ -628,7 +625,6 @@
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+23
View File
@@ -0,0 +1,23 @@
import Foundation
enum NativeCore {
private static let library = dlopen("@rpath/immich_core_ffi.framework/immich_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("immich_core_thumbhash", as: ImmichCoreThumbhashFn.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))
}
}
}
@@ -42,9 +42,10 @@ class LocalImageApiImpl: LocalImageApi {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
guard let (width, height, pointer) = NativeCore.thumbhash(data)
else { return completion(.failure(PigeonError(code: "", message: "invalid thumbhash", details: nil)))}
completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
"pointer": Int64(Int(bitPattern: pointer)),
"width": Int64(width),
"height": Int64(height),
"rowBytes": Int64(width * 4)
-225
View File
@@ -1,225 +0,0 @@
// 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.
import Foundation
// NOTE: Swift has an exponential-time type checker and compiling very simple
// expressions can easily take many seconds, especially when expressions involve
// numeric type constructors.
//
// This file deliberately breaks compound expressions up into separate variables
// to improve compile time even though this comes at the expense of readability.
// This is a known workaround for this deficiency in the Swift compiler.
//
// The following command is helpful when debugging Swift compile time issues:
//
// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies
//
// These optimizations brought the compile time for this file from around 2.5
// seconds to around 250ms (10x faster).
// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is
// really awful. Debug builds compile a very generic indexing iterator thing
// that makes many nested calls for every iteration, which makes debug-build
// performance crawl.
//
// This file deliberately avoids for-in loops that loop for more than a few
// times to improve debug-build run time even though this comes at the expense
// of readability. Similarly unsafe pointers are used instead of array getters
// to avoid unnecessary bounds checks, which have extra overhead in debug builds.
//
// These optimizations brought the run time to encode and decode 10 ThumbHashes
// in debug mode from 700ms to 70ms (10x faster).
// changed signature and allocation method to avoid automatic GC
func thumbHashToRGBA(hash: Data) -> (Int, Int, UnsafeMutableRawBufferPointer) {
// Read the constants
let h0 = UInt32(hash[0])
let h1 = UInt32(hash[1])
let h2 = UInt32(hash[2])
let h3 = UInt16(hash[3])
let h4 = UInt16(hash[4])
let header24 = h0 | (h1 << 8) | (h2 << 16)
let header16 = h3 | (h4 << 8)
let il_dc = header24 & 63
let ip_dc = (header24 >> 6) & 63
let iq_dc = (header24 >> 12) & 63
var l_dc = Float32(il_dc)
var p_dc = Float32(ip_dc)
var q_dc = Float32(iq_dc)
l_dc = l_dc / 63
p_dc = p_dc / 31.5 - 1
q_dc = q_dc / 31.5 - 1
let il_scale = (header24 >> 18) & 31
var l_scale = Float32(il_scale)
l_scale = l_scale / 31
let hasAlpha = (header24 >> 23) != 0
let ip_scale = (header16 >> 3) & 63
let iq_scale = (header16 >> 9) & 63
var p_scale = Float32(ip_scale)
var q_scale = Float32(iq_scale)
p_scale = p_scale / 63
q_scale = q_scale / 63
let isLandscape = (header16 >> 15) != 0
let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
let lx = Int(lx16)
let ly = Int(ly16)
var a_dc = Float32(1)
var a_scale = Float32(1)
if hasAlpha {
let ia_dc = hash[5] & 15
let ia_scale = hash[5] >> 4
a_dc = Float32(ia_dc)
a_scale = Float32(ia_scale)
a_dc /= 15
a_scale /= 15
}
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
let ac_start = hasAlpha ? 6 : 5
var ac_index = 0
let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in
var ac: [Float32] = []
for cy in 0 ..< ny {
var cx = cy > 0 ? 0 : 1
while cx * ny < nx * (ny - cy) {
let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15;
var fac = Float32(iac)
fac = (fac / 7.5 - 1) * scale
ac.append(fac)
ac_index += 1
cx += 1
}
}
return ac
}
let l_ac = decodeChannel(lx, ly, l_scale)
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : []
// Decode using the DCT into RGB
let ratio = thumbHashToApproximateAspectRatio(hash: hash)
let fw = round(ratio > 1 ? 32 : 32 * ratio)
let fh = round(ratio > 1 ? 32 / ratio : 32)
let w = Int(fw)
let h = Int(fh)
let pointer = UnsafeMutableRawBufferPointer.allocate(
byteCount: w * h * 4,
alignment: MemoryLayout<UInt8>.alignment
)
var rgba = pointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
let cx_stop = max(lx, hasAlpha ? 5 : 3)
let cy_stop = max(ly, hasAlpha ? 5 : 3)
var fx = [Float32](repeating: 0, count: cx_stop)
var fy = [Float32](repeating: 0, count: cy_stop)
fx.withUnsafeMutableBytes { fx in
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
fy.withUnsafeMutableBytes { fy in
let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count)
var y = 0
while y < h {
var x = 0
while x < w {
var l = l_dc
var p = p_dc
var q = q_dc
var a = a_dc
// Precompute the coefficients
var cx = 0
while cx < cx_stop {
let fw = Float32(w)
let fxx = Float32(x)
let fcx = Float32(cx)
fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx)
cx += 1
}
var cy = 0
while cy < cy_stop {
let fh = Float32(h)
let fyy = Float32(y)
let fcy = Float32(cy)
fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy)
cy += 1
}
// Decode L
var j = 0
cy = 0
while cy < ly {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx * ly < lx * (ly - cy) {
l += l_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
// Decode P and Q
j = 0
cy = 0
while cy < 3 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 3 - cy {
let f = fx[cx] * fy2
p += p_ac[j] * f
q += q_ac[j] * f
j += 1
cx += 1
}
cy += 1
}
// Decode A
if hasAlpha {
j = 0
cy = 0
while cy < 5 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 5 - cy {
a += a_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
}
// Convert to RGB
var b = l - 2 / 3 * p
var r = (3 * l - b + q) / 2
var g = r - q
r = max(0, 255 * min(1, r))
g = max(0, 255 * min(1, g))
b = max(0, 255 * min(1, b))
a = max(0, 255 * min(1, a))
rgba[0] = UInt8(r)
rgba[1] = UInt8(g)
rgba[2] = UInt8(b)
rgba[3] = UInt8(a)
rgba = rgba.advanced(by: 4)
x += 1
}
y += 1
}
}
}
return (w, h, pointer)
}
func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 {
let header = hash[3]
let hasAlpha = (hash[2] & 0x80) != 0
let isLandscape = (hash[4] & 0x80) != 0
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
return Float32(lx) / Float32(ly)
}
@@ -1 +1,5 @@
#import "GeneratedPluginRegistrant.h"
#include "../../../native/crates/immich_core_ffi/include/immich_core.h"
// The function pointer type comes from the declaration, so nothing is retyped here.
typedef __typeof__(&immich_core_thumbhash) ImmichCoreThumbhashFn;
@@ -1,15 +1,14 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_title.widget.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
class MemoryCard extends StatelessWidget {
final RemoteAsset asset;
@@ -87,19 +86,21 @@ class MemoryCard extends StatelessWidget {
}
}
class _BlurredBackdrop extends HookWidget {
class _BlurredBackdrop extends StatelessWidget {
final RemoteAsset asset;
const _BlurredBackdrop({required this.asset});
@override
Widget build(BuildContext context) {
final blurhash = useDriftBlurHashRef(asset).value;
if (blurhash != null) {
if (asset.thumbHash != null) {
// Use a nice cheap blur hash image decoration
return DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(image: MemoryImage(blurhash), fit: BoxFit.cover),
image: DecorationImage(
image: ThumbHashProvider(thumbHash: asset.thumbHash!),
fit: BoxFit.cover,
),
),
child: Container(color: Colors.black.withValues(alpha: 0.2)),
);
-16
View File
@@ -1,16 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:thumbhash/thumbhash.dart' as thumbhash;
ObjectRef<Uint8List?> useDriftBlurHashRef(RemoteAsset? asset) {
if (asset?.thumbHash == null) {
return useRef(null);
}
final rbga = thumbhash.thumbHashToRGBA(base64Decode(asset!.thumbHash!));
return useRef(thumbhash.rgbaToBmp(rbga));
}
-8
View File
@@ -1771,14 +1771,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
thumbhash:
dependency: "direct main"
description:
name: thumbhash
sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37"
url: "https://pub.dev"
source: hosted
version: "0.1.0+1"
timezone:
dependency: "direct main"
description:
-1
View File
@@ -72,7 +72,6 @@ dependencies:
sqlite3: ^3.4.0
sqlite_async: 0.14.2
sqlite3_connection_pool: ^0.2.7
thumbhash: 0.1.0+1
timezone: ^0.9.4
url_launcher: ^6.3.2
uuid: ^4.6.0
+1
View File
@@ -1,5 +1,6 @@
pub mod convert;
pub mod rotate;
pub mod thumbhash;
pub fn core_version() -> &'static str {
env!("CARGO_PKG_VERSION")
@@ -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,97 @@
use immich_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}");
}
}
+1
View File
@@ -3,6 +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/thumbhash.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
cbindgen::generate(&crate_dir)
+1
View File
@@ -11,6 +11,7 @@ mod bitmap;
mod convert;
#[cfg(target_os = "android")]
mod rotate;
mod thumbhash;
/// Returns the core version as a C string. Free it with `immich_core_free_string`.
#[unsafe(no_mangle)]
@@ -0,0 +1,79 @@
use std::{ptr, slice};
use libc::size_t;
use immich_core::thumbhash;
#[cfg(target_os = "android")]
use jni::{
EnvUnowned,
errors::ThrowRuntimeExAndDefault,
objects::{JByteArray, JClass, JIntArray},
sys::jlong,
};
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 immich_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 { immich_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>()
}