Skip to content

Commit bbe0dd0

Browse files
JSKittyclaude
andcommitted
refactor: replace BlurHash with ThumbHash via fast-thumbhash
Migrate from blurhash 0.2.3 to fast-thumbhash 0.2, our custom SIMD-accelerated ThumbHash encoder/decoder with base91 encoding. Performance (Apple M-series): - Encode: 12.4x faster (47.1μs vs 583.7μs) - Decode: 11.3x faster (18.8μs vs 213.1μs) Wire efficiency (base91 vs BlurHash base83): - 26 chars avg vs 28 chars — 7.1% smaller over 10k images - Encodes strictly more data: alpha channel, aspect ratio, full DCT Visual fidelity: - ThumbHash preserves alpha transparency - Encodes aspect ratio intrinsically (no external dim tag needed for previews) - Higher-fidelity DCT reconstruction vs BlurHash's fixed grid Changes across 17 files: - Cargo.toml: blurhash → fast-thumbhash dependency - util.rs: Replaced adaptive percentage scaling with simple 100x100 max nearest-neighbor downsampling + base91 encoding. Simplified decoder (no width/height/punch params needed — encoded in the hash itself) - commands/attachments.rs: Renamed commands, simplified decode_thumbhash signature (just takes the hash string) - sending.rs, compression.rs, files.rs: All call sites updated - rumor.rs: Parse "thumbhash" imeta tags instead of "blurhash" - types.rs: ImageMetadata.blurhash → .thumbhash (base91-encoded) - main.js: Renamed cache, functions, invoke calls, GIF picker integration - ACL: New permission TOMLs + capabilities for renamed commands Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ce1074 commit bbe0dd0

17 files changed

Lines changed: 215 additions & 208 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ tauri-plugin-clipboard-manager = "2.3.2"
4747
tauri-plugin-process = "2.3.1"
4848
tauri-plugin-deep-link = "2.4.7"
4949
image = { version = "0.25.9", default-features = false, features = ["png", "jpeg", "gif", "webp", "tiff", "ico"] }
50-
blurhash = "0.2.3"
50+
fast-thumbhash = "0.2"
5151
base64 = "0.22.1"
5252
cpal = "0.16.0"
5353
hound = "3.5.1"

src-tauri/capabilities/default.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@
6868
"allow-get-system-events",
6969
"allow-get-chat-message-count",
7070
"allow-evict-chat-messages",
71-
"allow-generate-blurhash-preview",
72-
"allow-decode-blurhash",
71+
"allow-generate-thumbhash-preview",
72+
"allow-decode-thumbhash",
7373
"allow-download-attachment",
7474
"allow-login",
7575
"allow-login-from-stored-key",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Automatically generated - DO NOT EDIT!
2+
3+
[[permission]]
4+
identifier = "allow-decode-thumbhash"
5+
description = "Enables the decode_thumbhash command without any pre-configured scope."
6+
commands.allow = ["decode_thumbhash"]
7+
8+
[[permission]]
9+
identifier = "deny-decode-thumbhash"
10+
description = "Denies the decode_thumbhash command without any pre-configured scope."
11+
commands.deny = ["decode_thumbhash"]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Automatically generated - DO NOT EDIT!
2+
3+
[[permission]]
4+
identifier = "allow-generate-thumbhash-preview"
5+
description = "Enables the generate_thumbhash_preview command without any pre-configured scope."
6+
commands.allow = ["generate_thumbhash_preview"]
7+
8+
[[permission]]
9+
identifier = "deny-generate-thumbhash-preview"
10+
description = "Denies the generate_thumbhash_preview command without any pre-configured scope."
11+
commands.deny = ["generate_thumbhash_preview"]

src-tauri/src/commands/attachments.rs

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Attachment handling Tauri commands.
22
//!
33
//! This module handles attachment operations:
4-
//! - Blurhash preview generation and decoding
4+
//! - ThumbHash preview generation and decoding
55
//! - Attachment download, decryption, and saving
66
//! - MLS attachment decryption (MIP-04)
77
@@ -148,9 +148,9 @@ async fn decrypt_mls_attachment(
148148
// Tauri Commands
149149
// ============================================================================
150150

151-
/// Generate a blurhash preview for an attachment
151+
/// Generate a thumbhash preview for an attachment
152152
#[tauri::command]
153-
pub async fn generate_blurhash_preview(npub: String, msg_id: String) -> Result<String, String> {
153+
pub async fn generate_thumbhash_preview(npub: String, msg_id: String) -> Result<String, String> {
154154
// Get the first attachment from the message by searching through chats
155155
let img_meta = {
156156
let state = STATE.lock().await;
@@ -180,22 +180,17 @@ pub async fn generate_blurhash_preview(npub: String, msg_id: String) -> Result<S
180180
found_attachment.ok_or_else(|| "No image attachment found".to_string())?
181181
};
182182

183-
// Generate the Base64 image using the decode_blurhash_to_base64 function
184-
let base64_image = util::decode_blurhash_to_base64(
185-
&img_meta.blurhash,
186-
img_meta.width,
187-
img_meta.height,
188-
1.0 // Default punch value
189-
);
183+
// Generate the Base64 image using the decode_thumbhash_to_base64 function
184+
let base64_image = util::decode_thumbhash_to_base64(&img_meta.thumbhash);
190185

191186
Ok(base64_image)
192187
}
193188

194-
/// Generic blurhash decoder - converts a blurhash string to a base64 data URL
189+
/// Generic thumbhash decoder - converts a thumbhash string to a base64 data URL
195190
/// Used by the GIF picker for placeholder backgrounds
196191
#[tauri::command]
197-
pub fn decode_blurhash(blurhash: String, width: u32, height: u32) -> String {
198-
util::decode_blurhash_to_base64(&blurhash, width, height, 1.0)
192+
pub fn decode_thumbhash(thumbhash: String) -> String {
193+
util::decode_thumbhash_to_base64(&thumbhash)
199194
}
200195

201196
/// Download and decrypt an attachment
@@ -434,6 +429,6 @@ pub async fn download_attachment(npub: String, msg_id: String, attachment_id: St
434429
}
435430

436431
// Handler list for this module (for reference):
437-
// - generate_blurhash_preview
438-
// - decode_blurhash
432+
// - generate_thumbhash_preview
433+
// - decode_thumbhash
439434
// - download_attachment

src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//!
33
//! This module organizes Tauri commands into logical categories:
44
//! - `account`: Authentication and account management (5 commands)
5-
//! - `attachments`: File downloads and blurhash processing (3 commands)
5+
//! - `attachments`: File downloads and thumbhash processing (3 commands)
66
//! - `invites`: Invite codes and badges (4 commands)
77
//! - `media`: Voice recording and transcription (4 commands)
88
//! - `relays`: Relay management, connection, monitoring (13 commands)

src-tauri/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,8 +463,8 @@ pub fn run() {
463463
commands::relays::get_relay_logs,
464464
commands::relays::monitor_relay_connections,
465465
// Attachment commands (commands/attachments.rs)
466-
commands::attachments::generate_blurhash_preview,
467-
commands::attachments::decode_blurhash,
466+
commands::attachments::generate_thumbhash_preview,
467+
commands::attachments::decode_thumbhash,
468468
commands::attachments::download_attachment,
469469
// Sync commands (commands/sync.rs)
470470
commands::sync::queue_profile_sync,

src-tauri/src/message/compression.rs

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! - Image compression with resize to max 1920px
55
//! - GIF preservation (skip compression to keep animation)
66
//! - PNG for transparent images, JPEG for opaque
7-
//! - Blurhash generation for previews
7+
//! - ThumbHash generation for previews
88
99
use std::sync::Arc;
1010

@@ -36,9 +36,9 @@ pub(super) fn compress_bytes_internal(
3636

3737
let (width, height) = (img.width(), img.height());
3838

39-
let img_meta = crate::util::generate_blurhash_from_image(&img)
40-
.map(|blurhash| ImageMetadata {
41-
blurhash,
39+
let img_meta = crate::util::generate_thumbhash_from_image(&img)
40+
.map(|thumbhash| ImageMetadata {
41+
thumbhash,
4242
width,
4343
height,
4444
});
@@ -71,10 +71,10 @@ pub(super) fn compress_bytes_internal(
7171
let actual_width = resized_img.width();
7272
let actual_height = resized_img.height();
7373

74-
// Generate metadata from final image only (avoid redundant blurhash generation)
75-
let final_meta = crate::util::generate_blurhash_from_image(&resized_img)
76-
.map(|blurhash| ImageMetadata {
77-
blurhash,
74+
// Generate metadata from final image only (avoid redundant thumbhash generation)
75+
let final_meta = crate::util::generate_thumbhash_from_image(&resized_img)
76+
.map(|thumbhash| ImageMetadata {
77+
thumbhash,
7878
width: actual_width,
7979
height: actual_height,
8080
});
@@ -139,17 +139,17 @@ pub(super) fn compress_image_internal(file_path: &str) -> Result<CachedCompresse
139139
let original_size = bytes.len() as u64;
140140

141141
// For GIFs, skip compression entirely to preserve animation
142-
// Just decode first frame for blurhash, then return original bytes
142+
// Just decode first frame for thumbhash, then return original bytes
143143
if extension == "gif" {
144-
// Decode just to get dimensions and generate blurhash from first frame
144+
// Decode just to get dimensions and generate thumbhash from first frame
145145
let img = ::image::load_from_memory(&bytes)
146146
.map_err(|e| format!("Failed to decode GIF: {}", e))?;
147147

148148
let (width, height) = (img.width(), img.height());
149149

150-
let img_meta = crate::util::generate_blurhash_from_image(&img)
151-
.map(|blurhash| ImageMetadata {
152-
blurhash,
150+
let img_meta = crate::util::generate_thumbhash_from_image(&img)
151+
.map(|thumbhash| ImageMetadata {
152+
thumbhash,
153153
width,
154154
height,
155155
});
@@ -183,9 +183,9 @@ pub(super) fn compress_image_internal(file_path: &str) -> Result<CachedCompresse
183183
let actual_width = resized_img.width();
184184
let actual_height = resized_img.height();
185185

186-
let img_meta = crate::util::generate_blurhash_from_image(&resized_img)
187-
.map(|blurhash| ImageMetadata {
188-
blurhash,
186+
let img_meta = crate::util::generate_thumbhash_from_image(&resized_img)
187+
.map(|thumbhash| ImageMetadata {
188+
thumbhash,
189189
width: actual_width,
190190
height: actual_height,
191191
});
@@ -228,9 +228,9 @@ pub(super) fn compress_image_internal(file_path: &str) -> Result<CachedCompresse
228228

229229
let (width, height) = (img.width(), img.height());
230230

231-
let img_meta = crate::util::generate_blurhash_from_image(&img)
232-
.map(|blurhash| ImageMetadata {
233-
blurhash,
231+
let img_meta = crate::util::generate_thumbhash_from_image(&img)
232+
.map(|thumbhash| ImageMetadata {
233+
thumbhash,
234234
width,
235235
height,
236236
});
@@ -263,9 +263,9 @@ pub(super) fn compress_image_internal(file_path: &str) -> Result<CachedCompresse
263263
let actual_width = resized_img.width();
264264
let actual_height = resized_img.height();
265265

266-
let img_meta = crate::util::generate_blurhash_from_image(&resized_img)
267-
.map(|blurhash| ImageMetadata {
268-
blurhash,
266+
let img_meta = crate::util::generate_thumbhash_from_image(&resized_img)
267+
.map(|thumbhash| ImageMetadata {
268+
thumbhash,
269269
width: actual_width,
270270
height: actual_height,
271271
});

src-tauri/src/message/files.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,9 @@ pub async fn send_cached_file(receiver: String, replied_to: String, use_compress
198198
// Process images: generate metadata and optionally compress
199199
let (bytes, extension, img_meta) = if is_image {
200200
if let Ok(img) = ::image::load_from_memory(&original_bytes) {
201-
let blurhash_meta = crate::util::generate_blurhash_from_image(&img)
202-
.map(|blurhash| ImageMetadata {
203-
blurhash,
201+
let thumbhash_meta = crate::util::generate_thumbhash_from_image(&img)
202+
.map(|thumbhash| ImageMetadata {
203+
thumbhash,
204204
width: img.width(),
205205
height: img.height(),
206206
});
@@ -209,14 +209,14 @@ pub async fn send_cached_file(receiver: String, replied_to: String, use_compress
209209
// Other images: compress if requested
210210
if original_extension == "gif" || !use_compression {
211211
// No compression - just use original bytes with metadata
212-
(original_bytes, original_extension, blurhash_meta)
212+
(original_bytes, original_extension, thumbhash_meta)
213213
} else {
214214
// Compress on-the-fly since pre-compression wasn't ready
215215
use crate::shared::image::{encode_rgba_auto, JPEG_QUALITY_STANDARD};
216216
let rgba_img = img.to_rgba8();
217217
match encode_rgba_auto(rgba_img.as_raw(), img.width(), img.height(), JPEG_QUALITY_STANDARD) {
218-
Ok(encoded) => (Arc::new(encoded.bytes), encoded.extension.to_string(), blurhash_meta),
219-
Err(_) => (original_bytes, original_extension, blurhash_meta),
218+
Ok(encoded) => (Arc::new(encoded.bytes), encoded.extension.to_string(), thumbhash_meta),
219+
Err(_) => (original_bytes, original_extension, thumbhash_meta),
220220
}
221221
}
222222
} else {
@@ -375,9 +375,9 @@ pub async fn file_message(receiver: String, replied_to: String, file_path: Strin
375375
// Generate image metadata if the file is an image
376376
if matches!(attachment_file.extension.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "tiff" | "tif" | "ico") {
377377
if let Ok(img) = ::image::load_from_memory(&attachment_file.bytes) {
378-
attachment_file.img_meta = util::generate_blurhash_from_image(&img)
379-
.map(|blurhash| ImageMetadata {
380-
blurhash,
378+
attachment_file.img_meta = util::generate_thumbhash_from_image(&img)
379+
.map(|thumbhash| ImageMetadata {
380+
thumbhash,
381381
width: img.width(),
382382
height: img.height(),
383383
});
@@ -692,9 +692,9 @@ pub async fn file_message_compressed(receiver: String, replied_to: String, file_
692692
img
693693
};
694694

695-
attachment_file.img_meta = crate::util::generate_blurhash_from_image(&resized_img)
696-
.map(|blurhash| ImageMetadata {
697-
blurhash,
695+
attachment_file.img_meta = crate::util::generate_thumbhash_from_image(&resized_img)
696+
.map(|thumbhash| ImageMetadata {
697+
thumbhash,
698698
width: resized_img.width(),
699699
height: resized_img.height(),
700700
});

0 commit comments

Comments
 (0)