From 2510413b4b9306d5ae171020210f252e47e03fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Morel?= <137194052+LoicPandul@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:27:37 +0200 Subject: [PATCH 1/6] background removal --- src-tauri/Cargo.lock | 295 +++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 8 + src-tauri/src/assets.rs | 261 ++++++++++++++++++++++++++++ src-tauri/src/commands.rs | 141 +++++++++++---- src-tauri/src/engine/matting.rs | 159 +++++++++++++++++ src-tauri/src/engine/mod.rs | 55 +++++- src-tauri/src/lib.rs | 10 +- src-tauri/tests/engine.rs | 85 +++++++++ ui/index.html | 27 +++ ui/main.js | 121 +++++++++++++ ui/styles.css | 77 +++++++++ 11 files changed, 1200 insertions(+), 39 deletions(-) create mode 100644 src-tauri/src/assets.rs create mode 100644 src-tauri/src/engine/matting.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f94620a..86c2992 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -715,6 +724,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1011,6 +1031,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1790,20 +1820,28 @@ version = "2.0.0" dependencies = [ "base64 0.22.1", "crc32fast", + "flate2", + "half", "image", "imagequant", "kamadak-exif", + "ndarray", + "ort", "png 0.17.16", "rayon", "rgb", "serde", + "sha2", + "tar", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-window-state", + "ureq", "webp", + "zip", ] [[package]] @@ -2019,7 +2057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -2048,6 +2086,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.18" @@ -2105,6 +2153,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2184,6 +2242,21 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2214,12 +2287,30 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2480,6 +2571,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52afb44b6b0cffa9bf45e4d37e5a4935b0334a51570658e279e9e3e6cf324aa5" +dependencies = [ + "half", + "libloading 0.8.9", + "ndarray", + "ort-sys", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41d7757331aef2d04b9cb09b45583a59217628beaf91895b7e76187b6e8c088" +dependencies = [ + "pkg-config", +] + [[package]] name = "pango" version = "0.18.3" @@ -2669,6 +2782,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2800,6 +2928,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" @@ -2956,6 +3090,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2984,6 +3132,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3388,6 +3571,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3504,6 +3693,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4301,6 +4501,27 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -4553,6 +4774,24 @@ dependencies = [ "libwebp-sys", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4789,6 +5028,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5131,6 +5379,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5256,6 +5514,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -5289,12 +5553,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 19aa68a..9180c1e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,14 @@ rgb = "0.8" png = "0.17" kamadak-exif = "0.6" rayon = "1" +ort = { version = "=2.0.0-rc.9", default-features = false, features = ["load-dynamic", "ndarray", "half"] } +ndarray = "0.16" +half = "2" +ureq = { version = "2", default-features = false, features = ["tls"] } +sha2 = "0.10" +zip = { version = "2", default-features = false, features = ["deflate"] } +tar = "0.4" +flate2 = "1" base64 = "0.22" [dev-dependencies] diff --git a/src-tauri/src/assets.rs b/src-tauri/src/assets.rs new file mode 100644 index 0000000..1a409cf --- /dev/null +++ b/src-tauri/src/assets.rs @@ -0,0 +1,261 @@ +//! First-use download of the background-removal assets: the onnxruntime +//! dynamic library (pinned official Microsoft release) and the BiRefNet-lite +//! model (pinned Hugging Face revision, MIT). Downloads are sha256-verified +//! and installed atomically under the app data directory; after that the +//! feature is fully offline. + +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +const ORT_BASE: &str = "https://github.com/microsoft/onnxruntime/releases/download/v1.22.0"; +/// ISNet general-use (DIS, Apache-2.0) — the rembg workhorse. BiRefNet-lite +/// was evaluated first for quality but needs ~80 s per image on CPU; +/// ISNet lands in the couple-of-seconds range with very close results. +const MODEL_URL: &str = + "https://github.com/danielgatis/rembg/releases/download/v0.0.0/isnet-general-use.onnx"; +const MODEL_SHA256: &str = "60920e99c45464f2ba57bee2ad08c919a52bbf852739e96947fbb4358c0d964a"; + +struct RemoteAsset { + label: &'static str, + url: String, + sha256: &'static str, + /// Entry to pull out of the downloaded archive (None = plain file). + archive_entry: Option, + dest_name: &'static str, + download_bytes: u64, +} + +#[cfg(target_os = "windows")] +fn runtime_asset() -> RemoteAsset { + RemoteAsset { + label: "onnxruntime", + url: format!("{ORT_BASE}/onnxruntime-win-x64-1.22.0.zip"), + sha256: "174c616efc0271194488642a72f1a514e01487da4dfe84c49296d66e40ebe0da", + archive_entry: Some("onnxruntime-win-x64-1.22.0/lib/onnxruntime.dll".into()), + dest_name: "onnxruntime.dll", + download_bytes: 72_368_545, + } +} + +#[cfg(target_os = "linux")] +fn runtime_asset() -> RemoteAsset { + RemoteAsset { + label: "onnxruntime", + url: format!("{ORT_BASE}/onnxruntime-linux-x64-1.22.0.tgz"), + sha256: "8344d55f93d5bc5021ce342db50f62079daf39aaafb5d311a451846228be49b3", + archive_entry: Some("onnxruntime-linux-x64-1.22.0/lib/libonnxruntime.so.1.22.0".into()), + dest_name: "libonnxruntime.so", + download_bytes: 7_798_730, + } +} + +#[cfg(target_os = "macos")] +fn runtime_asset() -> RemoteAsset { + RemoteAsset { + label: "onnxruntime", + url: format!("{ORT_BASE}/onnxruntime-osx-universal2-1.22.0.tgz"), + sha256: "cfa6f6584d87555ed9f6e7e8a000d3947554d589efe3723b8bfa358cd263d03c", + archive_entry: Some( + "onnxruntime-osx-universal2-1.22.0/lib/libonnxruntime.1.22.0.dylib".into(), + ), + dest_name: "libonnxruntime.dylib", + download_bytes: 54_820_264, + } +} + +fn model_asset() -> RemoteAsset { + RemoteAsset { + label: "model", + url: MODEL_URL.into(), + sha256: MODEL_SHA256, + archive_entry: None, + dest_name: "isnet-general-use.onnx", + download_bytes: 178_648_008, + } +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BgAssetsStatus { + pub ready: bool, + pub missing_bytes: u64, +} + +/// Progress reported to the UI while installing. +#[derive(Serialize, Clone)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum InstallEvent { + Progress { received: u64, total: u64 }, + Done, +} + +#[derive(Clone)] +pub struct BgAssets { + dir: PathBuf, +} + +impl BgAssets { + pub fn new(app_data_dir: PathBuf) -> Self { + Self { + dir: app_data_dir.join("bg-removal"), + } + } + + pub fn dylib_path(&self) -> PathBuf { + self.dir.join(runtime_asset().dest_name) + } + + pub fn model_path(&self) -> PathBuf { + self.dir.join(model_asset().dest_name) + } + + fn missing(&self) -> Vec { + [runtime_asset(), model_asset()] + .into_iter() + .filter(|a| !self.dir.join(a.dest_name).is_file()) + .collect() + } + + pub fn status(&self) -> BgAssetsStatus { + let missing = self.missing(); + BgAssetsStatus { + ready: missing.is_empty(), + missing_bytes: missing.iter().map(|a| a.download_bytes).sum(), + } + } + + /// Download and install whatever is missing. `progress(received, total)` + /// is called with byte counts across all pending downloads. + pub fn install(&self, progress: impl Fn(u64, u64)) -> Result<(), String> { + let missing = self.missing(); + if missing.is_empty() { + return Ok(()); + } + fs::create_dir_all(&self.dir).map_err(|e| format!("create dir: {e}"))?; + let total: u64 = missing.iter().map(|a| a.download_bytes).sum(); + let mut done: u64 = 0; + for asset in &missing { + self.install_one(asset, |received| progress(done + received, total))?; + done += asset.download_bytes; + progress(done, total); + } + Ok(()) + } + + fn install_one(&self, asset: &RemoteAsset, progress: impl Fn(u64)) -> Result<(), String> { + let staged = self.dir.join(format!("{}.download", asset.dest_name)); + let dest = self.dir.join(asset.dest_name); + + download_verified(&asset.url, asset.sha256, &staged, &progress) + .map_err(|e| format!("{}: {e}", asset.label))?; + + let result = match &asset.archive_entry { + None => fs::rename(&staged, &dest).map_err(|e| format!("install: {e}")), + Some(entry) => { + let extracted = self.dir.join(format!("{}.extracted", asset.dest_name)); + extract_entry(&staged, entry, &extracted) + .and_then(|()| { + fs::rename(&extracted, &dest).map_err(|e| format!("install: {e}")) + }) + .inspect_err(|_| { + let _ = fs::remove_file(&extracted); + }) + } + }; + let _ = fs::remove_file(&staged); + result + } +} + +/// Stream the URL to `staged`, hashing on the fly; fail on sha mismatch. +fn download_verified( + url: &str, + expected_sha256: &str, + staged: &Path, + progress: &impl Fn(u64), +) -> Result<(), String> { + let response = ureq::get(url) + .timeout(std::time::Duration::from_secs(3600)) + .call() + .map_err(|e| format!("download: {e}"))?; + + let mut reader = response.into_reader(); + let mut file = fs::File::create(staged).map_err(|e| format!("write: {e}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 128 * 1024]; + let mut received: u64 = 0; + + loop { + let n = reader.read(&mut buffer).map_err(|e| { + let _ = fs::remove_file(staged); + format!("download: {e}") + })?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + std::io::Write::write_all(&mut file, &buffer[..n]).map_err(|e| { + let _ = fs::remove_file(staged); + format!("write: {e}") + })?; + received += n as u64; + progress(received); + } + drop(file); + + let digest = hasher.finalize(); + let actual: String = digest.iter().map(|b| format!("{b:02x}")).collect(); + if !actual.eq_ignore_ascii_case(expected_sha256) { + let _ = fs::remove_file(staged); + return Err(format!( + "checksum mismatch (expected {expected_sha256}, got {actual}) — download corrupted or upstream changed" + )); + } + Ok(()) +} + +/// Pull a single entry out of a .zip or .tgz archive (detected by magic). +fn extract_entry(archive: &Path, entry: &str, dest: &Path) -> Result<(), String> { + let mut magic = [0u8; 2]; + { + let mut f = fs::File::open(archive).map_err(|e| format!("open archive: {e}"))?; + f.read_exact(&mut magic) + .map_err(|e| format!("read archive: {e}"))?; + } + + if &magic == b"PK" { + let file = fs::File::open(archive).map_err(|e| format!("open archive: {e}"))?; + let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("zip: {e}"))?; + let mut wanted = zip + .by_name(entry) + .map_err(|e| format!("zip entry {entry}: {e}"))?; + let mut out = fs::File::create(dest).map_err(|e| format!("extract: {e}"))?; + std::io::copy(&mut wanted, &mut out).map_err(|e| format!("extract: {e}"))?; + return Ok(()); + } + + let file = fs::File::open(archive).map_err(|e| format!("open archive: {e}"))?; + let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file)); + for maybe_entry in tar.entries().map_err(|e| format!("tar: {e}"))? { + let mut tar_entry = maybe_entry.map_err(|e| format!("tar: {e}"))?; + let path = tar_entry + .path() + .map_err(|e| format!("tar: {e}"))? + .to_string_lossy() + .into_owned(); + if path == entry { + let mut out = fs::File::create(dest).map_err(|e| format!("extract: {e}"))?; + std::io::copy(&mut tar_entry, &mut out).map_err(|e| format!("extract: {e}"))?; + return Ok(()); + } + } + Err(format!("entry {entry} not found in archive")) +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b135833..71e608b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,13 +1,14 @@ use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use rayon::prelude::*; use serde::Serialize; use tauri::ipc::Channel; use tauri::State; -use crate::engine::{self, Options}; +use crate::assets::{BgAssets, BgAssetsStatus, InstallEvent}; +use crate::engine::{self, matting::Matting, Options}; #[derive(Default)] pub struct ConversionState { @@ -15,6 +16,65 @@ pub struct ConversionState { running: Arc, } +/// Background-removal state: asset store + lazily loaded model. +pub struct BgState { + assets: BgAssets, + matting: Mutex>>, + installing: AtomicBool, +} + +impl BgState { + pub fn new(app_data_dir: std::path::PathBuf) -> Self { + Self { + assets: BgAssets::new(app_data_dir), + matting: Mutex::new(None), + installing: AtomicBool::new(false), + } + } + + /// Load (once) and hand out the model. Errors carry user-readable text. + fn matting_handle(&self) -> Result, String> { + let mut guard = self.matting.lock().map_err(|_| "model state poisoned")?; + if guard.is_none() { + if !self.assets.status().ready { + return Err(engine::EngineError::MattingUnavailable.to_string()); + } + engine::matting::init_runtime(&self.assets.dylib_path()).map_err(|e| e.to_string())?; + let model = Matting::load(&self.assets.model_path()).map_err(|e| e.to_string())?; + *guard = Some(Arc::new(model)); + } + Ok(guard.as_ref().unwrap().clone()) + } +} + +#[tauri::command] +pub fn bg_status(bg: State<'_, BgState>) -> BgAssetsStatus { + bg.assets.status() +} + +#[tauri::command] +pub async fn bg_install( + on_event: Channel, + bg: State<'_, BgState>, +) -> Result<(), String> { + if bg.installing.swap(true, Ordering::SeqCst) { + return Err("an installation is already running".into()); + } + let assets = bg.assets.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + assets.install(|received, total| { + let _ = on_event.send(InstallEvent::Progress { received, total }); + })?; + let _ = on_event.send(InstallEvent::Done); + Ok(()) + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r: Result<(), String>| r); + bg.installing.store(false, Ordering::SeqCst); + result +} + #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct InspectedFile { @@ -91,6 +151,7 @@ pub enum ProgressEvent { out_bytes: Option, resized_to: Option<(u32, u32)>, lossless: bool, + background_removed: bool, warning: Option, }, Done { @@ -107,12 +168,25 @@ pub async fn convert_files( options: Options, on_event: Channel, state: State<'_, ConversionState>, + bg: State<'_, BgState>, ) -> Result<(), String> { if state.running.swap(true, Ordering::SeqCst) { return Err("a conversion is already running".into()); } state.cancel.store(false, Ordering::SeqCst); + let matting = if options.remove_background { + match bg.matting_handle() { + Ok(model) => Some(model), + Err(e) => { + state.running.store(false, Ordering::SeqCst); + return Err(e); + } + } + } else { + None + }; + let cancel = state.cancel.clone(); let running = state.running.clone(); @@ -126,38 +200,41 @@ pub async fn convert_files( return; } let _ = on_event.send(ProgressEvent::Start { path: path.clone() }); - let event = match engine::process_file(Path::new(path), &options) { - Ok(outcome) => { - succeeded.fetch_add(1, Ordering::SeqCst); - ProgressEvent::File { - path: path.clone(), - ok: true, - action: Some(outcome.action), - message: None, - out_path: Some(outcome.out_path.to_string_lossy().into_owned()), - in_bytes: Some(outcome.in_bytes), - out_bytes: Some(outcome.out_bytes), - resized_to: outcome.resized_to, - lossless: outcome.lossless, - warning: outcome.warning, + let event = + match engine::process_file_with(Path::new(path), &options, matting.as_deref()) { + Ok(outcome) => { + succeeded.fetch_add(1, Ordering::SeqCst); + ProgressEvent::File { + path: path.clone(), + ok: true, + action: Some(outcome.action), + message: None, + out_path: Some(outcome.out_path.to_string_lossy().into_owned()), + in_bytes: Some(outcome.in_bytes), + out_bytes: Some(outcome.out_bytes), + resized_to: outcome.resized_to, + lossless: outcome.lossless, + background_removed: outcome.background_removed, + warning: outcome.warning, + } } - } - Err(error) => { - failed.fetch_add(1, Ordering::SeqCst); - ProgressEvent::File { - path: path.clone(), - ok: false, - action: None, - message: Some(error.to_string()), - out_path: None, - in_bytes: None, - out_bytes: None, - resized_to: None, - lossless: false, - warning: None, + Err(error) => { + failed.fetch_add(1, Ordering::SeqCst); + ProgressEvent::File { + path: path.clone(), + ok: false, + action: None, + message: Some(error.to_string()), + out_path: None, + in_bytes: None, + out_bytes: None, + resized_to: None, + lossless: false, + background_removed: false, + warning: None, + } } - } - }; + }; let _ = on_event.send(event); }); diff --git a/src-tauri/src/engine/matting.rs b/src-tauri/src/engine/matting.rs new file mode 100644 index 0000000..c3d5f2b --- /dev/null +++ b/src-tauri/src/engine/matting.rs @@ -0,0 +1,159 @@ +//! Background removal with ISNet general-use (DIS, ONNX). The onnxruntime +//! library is loaded dynamically at runtime (`ort` load-dynamic): nothing is +//! linked at build time, both the runtime and the model are fetched on +//! first use. + +use std::path::Path; +use std::sync::Mutex; + +use image::imageops::FilterType; +use image::{DynamicImage, GrayImage, Luma, RgbaImage}; +use ndarray::Array4; +use ort::session::builder::GraphOptimizationLevel; +use ort::session::Session; + +use super::EngineError; + +/// ISNet is a fixed-size network. +const SIDE: u32 = 1024; +/// ISNet normalization (matches the reference rembg pipeline). +const MEAN: [f32; 3] = [0.5, 0.5, 0.5]; +const STD: [f32; 3] = [1.0, 1.0, 1.0]; + +/// Point ort at the onnxruntime dynamic library. Process-wide and +/// idempotent: only the first call performs the initialization. +pub fn init_runtime(dylib: &Path) -> Result<(), EngineError> { + static INIT: std::sync::OnceLock> = std::sync::OnceLock::new(); + let path = dylib.to_string_lossy().into_owned(); + INIT.get_or_init(|| { + ort::init_from(path) + .commit() + .map(|_| ()) + .map_err(|e| format!("onnxruntime init: {e}")) + }) + .clone() + .map_err(EngineError::Matting) +} + +/// A loaded background-removal model. `matte` serializes calls: onnxruntime +/// already parallelizes internally across cores. +pub struct Matting { + session: Mutex, + input_name: String, +} + +impl Matting { + pub fn load(model: &Path) -> Result { + let session = Session::builder() + .and_then(|b| b.with_optimization_level(GraphOptimizationLevel::Level3)) + .and_then(|b| b.with_intra_threads(num_threads())) + .and_then(|b| b.commit_from_file(model)) + .map_err(|e| EngineError::Matting(format!("model load: {e}")))?; + let input_name = session + .inputs + .first() + .map(|i| i.name.clone()) + .ok_or_else(|| EngineError::Matting("model has no input".into()))?; + Ok(Self { + session: Mutex::new(session), + input_name, + }) + } + + /// Soft alpha matte at the image's own resolution (0 = background, + /// 255 = subject). + pub fn matte(&self, image: &DynamicImage) -> Result { + let rgb = image + .resize_exact(SIDE, SIDE, FilterType::Triangle) + .to_rgb8(); + + let side = SIDE as usize; + let mut input = Array4::::zeros((1, 3, side, side)); + for (x, y, pixel) in rgb.enumerate_pixels() { + for c in 0..3 { + input[[0, c, y as usize, x as usize]] = + (pixel.0[c] as f32 / 255.0 - MEAN[c]) / STD[c]; + } + } + + let matte = { + let session = self + .session + .lock() + .map_err(|_| EngineError::Matting("model session poisoned".into()))?; + let outputs = session + .run( + ort::inputs![self.input_name.as_str() => input.view()] + .map_err(|e| EngineError::Matting(format!("inputs: {e}")))?, + ) + .map_err(|e| EngineError::Matting(format!("inference: {e}")))?; + let output = outputs + .iter() + .next() + .ok_or_else(|| EngineError::Matting("model returned no output".into()))? + .1; + // The fp16 export yields f16 tensors, the fp32 one yields f32. + if let Ok(view) = output.try_extract_tensor::() { + view.iter().copied().collect::>() + } else { + let view = output + .try_extract_tensor::() + .map_err(|e| EngineError::Matting(format!("output: {e}")))?; + view.iter().map(|v| v.to_f32()).collect::>() + } + }; + + if matte.len() < side * side { + return Err(EngineError::Matting(format!( + "unexpected output size {}", + matte.len() + ))); + } + + // Min-max stretch, like the reference ISNet pipeline: the raw map is + // in [0, 1] but rarely spans it fully. + let plane = &matte[..side * side]; + let (mut lo, mut hi) = (f32::MAX, f32::MIN); + for &v in plane { + lo = lo.min(v); + hi = hi.max(v); + } + let range = (hi - lo).max(f32::EPSILON); + let small = GrayImage::from_fn(SIDE, SIDE, |x, y| { + let v = (matte[y as usize * side + x as usize] - lo) / range; + Luma([(v.clamp(0.0, 1.0) * 255.0).round() as u8]) + }); + Ok(image::imageops::resize( + &small, + image.width(), + image.height(), + FilterType::Triangle, + )) + } +} + +/// Multiply the matte into the image's alpha channel (straight alpha). +/// Shoulders are clamped so near-misses become fully opaque/transparent, +/// which kills halos and helps compression. +pub fn apply_matte(image: &DynamicImage, matte: &GrayImage) -> RgbaImage { + let mut rgba = image.to_rgba8(); + for (pixel, m) in rgba.pixels_mut().zip(matte.pixels()) { + let a = shoulder(m.0[0]); + pixel.0[3] = ((u16::from(pixel.0[3]) * u16::from(a)) / 255) as u8; + } + rgba +} + +fn shoulder(a: u8) -> u8 { + match a { + 0..=7 => 0, + 248..=255 => 255, + other => other, + } +} + +fn num_threads() -> usize { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) +} diff --git a/src-tauri/src/engine/mod.rs b/src-tauri/src/engine/mod.rs index 691f01c..c329fb4 100644 --- a/src-tauri/src/engine/mod.rs +++ b/src-tauri/src/engine/mod.rs @@ -4,6 +4,7 @@ mod compress; mod decode; mod encode; +pub mod matting; mod strip; use std::ffi::OsStr; @@ -70,6 +71,10 @@ pub struct Options { #[serde(default)] pub max_size_kb: Option, pub delete_original: bool, + /// Cut the background out (transparent). Needs an alpha-capable target, + /// so JPEG is refused. Off by default. + #[serde(default)] + pub remove_background: bool, } #[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize)] @@ -94,6 +99,7 @@ pub struct Outcome { pub resized_to: Option<(u32, u32)>, /// True when metadata was removed without re-encoding pixels. pub lossless: bool, + pub background_removed: bool, pub warning: Option, } @@ -102,6 +108,9 @@ pub enum EngineError { Unsupported(String), Animated, JpegTransparency, + BackgroundNeedsAlpha, + MattingUnavailable, + Matting(String), Io(io::Error), Decode(String), Encode(String), @@ -121,6 +130,13 @@ impl fmt::Display for EngineError { EngineError::JpegTransparency => { write!(f, "image contains transparency, JPEG does not support it") } + EngineError::BackgroundNeedsAlpha => { + write!(f, "background removal needs WEBP or PNG as target") + } + EngineError::MattingUnavailable => { + write!(f, "background removal model is not installed") + } + EngineError::Matting(e) => write!(f, "background removal failed: {e}"), EngineError::Io(e) => write!(f, "file error: {e}"), EngineError::Decode(e) => write!(f, "could not read image: {e}"), EngineError::Encode(e) => write!(f, "could not encode image: {e}"), @@ -138,6 +154,16 @@ impl From for EngineError { /// Convert / compress / clean a single file according to `opts`. pub fn process_file(path: &Path, opts: &Options) -> Result { + process_file_with(path, opts, None) +} + +/// Same as [`process_file`], with the background-removal model available +/// when `opts.remove_background` is on. +pub fn process_file_with( + path: &Path, + opts: &Options, + matting: Option<&matting::Matting>, +) -> Result { let ext = path .extension() .and_then(OsStr::to_str) @@ -146,6 +172,9 @@ pub fn process_file(path: &Path, opts: &Options) -> Result if !is_supported_extension(&ext) { return Err(EngineError::Unsupported(ext)); } + if opts.remove_background && opts.format == TargetFormat::Jpeg { + return Err(EngineError::BackgroundNeedsAlpha); + } let bytes = fs::read(path)?; let in_bytes = bytes.len() as u64; @@ -163,15 +192,30 @@ pub fn process_file(path: &Path, opts: &Options) -> Result let same_format = opts.format.matches(decoded.format); let target_bytes = opts.max_size_kb.map(|kb| kb.saturating_mul(1024)); let upright = decoded.orientation == 1; + + // Cut the background before any encode decision. The pixels change, so + // every lossless shortcut below is disabled in that case. + let (working, background_removed) = if opts.remove_background { + let model = matting.ok_or(EngineError::MattingUnavailable)?; + let matte = model.matte(&decoded.image)?; + ( + image::DynamicImage::ImageRgba8(matting::apply_matte(&decoded.image, &matte)), + true, + ) + } else { + (decoded.image, false) + }; + let strip_lossless = || strip::strip_metadata(&bytes, decoded.format); + let may_strip = upright && !background_removed; let (data, action, resized_to, lossless, warning) = match target_bytes { // Metadata clean only. When the image needs no rotation, strip // metadata without touching the pixels. - None if same_format => match upright.then(strip_lossless).flatten() { + None if same_format => match may_strip.then(strip_lossless).flatten() { Some(data) => (data, Action::Cleaned, None, true, None), None => ( - encode::encode_clean(&decoded.image, opts.format)?, + encode::encode_clean(&working, opts.format)?, Action::Cleaned, None, false, @@ -180,7 +224,7 @@ pub fn process_file(path: &Path, opts: &Options) -> Result }, // Plain conversion. None => ( - encode::encode_default(&decoded.image, opts.format)?, + encode::encode_default(&working, opts.format)?, Action::Converted, None, false, @@ -190,13 +234,13 @@ pub fn process_file(path: &Path, opts: &Options) -> Result // re-encoded (that could grow it and degrade pixels): a lossless // metadata strip — which can only shrink the file — is all it needs. Some(budget) => { - let shortcut = (same_format && in_bytes <= budget && upright) + let shortcut = (same_format && in_bytes <= budget && may_strip) .then(strip_lossless) .flatten(); match shortcut { Some(data) => (data, Action::Cleaned, None, true, None), None => { - let result = compress::to_target_size(&decoded.image, opts.format, budget)?; + let result = compress::to_target_size(&working, opts.format, budget)?; let action = if same_format { Action::Compressed } else { @@ -238,6 +282,7 @@ pub fn process_file(path: &Path, opts: &Options) -> Result out_bytes, resized_to, lossless, + background_removed, warning, }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 994836b..a5554bf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod assets; mod commands; pub mod engine; @@ -6,6 +7,11 @@ use tauri_plugin_window_state::StateFlags; pub fn run() { tauri::Builder::default() + .setup(|app| { + let app_data = app.path().app_data_dir()?; + app.manage(commands::BgState::new(app_data)); + Ok(()) + }) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); @@ -24,7 +30,9 @@ pub fn run() { commands::inspect_files, commands::file_thumbnail, commands::convert_files, - commands::cancel_conversion + commands::cancel_conversion, + commands::bg_status, + commands::bg_install ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tests/engine.rs b/src-tauri/tests/engine.rs index 0b097ab..0421c23 100644 --- a/src-tauri/tests/engine.rs +++ b/src-tauri/tests/engine.rs @@ -27,6 +27,16 @@ fn opts(format: TargetFormat, max_size_kb: Option, delete_original: bool) - format, max_size_kb, delete_original, + remove_background: false, + } +} + +fn bg_opts(format: TargetFormat) -> Options { + Options { + format, + max_size_kb: None, + delete_original: false, + remove_background: true, } } @@ -400,6 +410,81 @@ fn mislabeled_extension_gets_normalized() { assert!(!src.exists()); } +// ---------- background removal ---------- + +#[test] +fn background_removal_to_jpeg_is_refused() { + let dir = scratch(); + let src = dir.join("photo.png"); + write_png(&src, &DynamicImage::ImageRgb8(photo(32, 32))); + + let err = process_file(&src, &bg_opts(TargetFormat::Jpeg)).unwrap_err(); + assert!(matches!(err, EngineError::BackgroundNeedsAlpha)); +} + +#[test] +fn background_removal_without_model_fails_cleanly() { + let dir = scratch(); + let src = dir.join("photo.png"); + write_png(&src, &DynamicImage::ImageRgb8(photo(32, 32))); + + let err = process_file(&src, &bg_opts(TargetFormat::Webp)).unwrap_err(); + assert!(matches!(err, EngineError::MattingUnavailable)); + assert!(src.exists(), "source must be untouched"); +} + +/// Real-model smoke test. Needs the downloaded assets; run manually with: +/// IC_BG_DYLIB=...onnxruntime.dll IC_BG_MODEL=...model.onnx cargo test real_model -- --ignored +#[test] +#[ignore = "needs the downloaded onnxruntime + BiRefNet model"] +fn real_model_cuts_white_background() { + use imagesconverter_lib::engine::matting; + let dylib = std::env::var("IC_BG_DYLIB").expect("set IC_BG_DYLIB"); + let model = std::env::var("IC_BG_MODEL").expect("set IC_BG_MODEL"); + matting::init_runtime(Path::new(&dylib)).unwrap(); + let matting = matting::Matting::load(Path::new(&model)).unwrap(); + + // A dark, detailed "subject" centered on a plain white background. + let mut img = image::RgbImage::from_pixel(640, 480, image::Rgb([245, 246, 248])); + for y in 120..360u32 { + for x in 220..420u32 { + let shade = 40 + ((x + y) % 60) as u8; + img.put_pixel(x, y, image::Rgb([shade, shade / 2, 20])); + } + } + let src = DynamicImage::ImageRgb8(img); + + let t = std::time::Instant::now(); + let matte = matting.matte(&src).unwrap(); + eprintln!("matte() took {:?}", t.elapsed()); + let corner = matte.get_pixel(5, 5).0[0]; + let center = matte.get_pixel(320, 240).0[0]; + assert!( + corner < 30, + "background corner should be cut (got {corner})" + ); + assert!(center > 220, "subject center should be kept (got {center})"); + + // Full pipeline: PNG out must actually carry transparency. + let dir = scratch(); + let path = dir.join("subject.png"); + src.save_with_format(&path, image::ImageFormat::Png) + .unwrap(); + let outcome = imagesconverter_lib::engine::process_file_with( + &path, + &bg_opts(TargetFormat::Png), + Some(&matting), + ) + .unwrap(); + assert!(outcome.background_removed); + let out = image::open(&outcome.out_path).unwrap(); + assert_eq!(out.get_pixel(5, 5).0[3], 0, "corner must be transparent"); + assert!( + out.get_pixel(320, 240).0[3] > 200, + "subject must stay opaque" + ); +} + // ---------- compression ---------- #[test] diff --git a/ui/index.html b/ui/index.html index 8375eb1..2f5f112 100644 --- a/ui/index.html +++ b/ui/index.html @@ -104,6 +104,33 @@

Queue

+
+ Remove background +
+ + + +
+
+ + + + +
diff --git a/ui/main.js b/ui/main.js index 8766da7..e7146b1 100644 --- a/ui/main.js +++ b/ui/main.js @@ -22,6 +22,15 @@ const els = { compress: $("compress"), maxKb: $("max-kb"), deleteOriginal: $("delete-original"), + removeBg: $("remove-bg"), + bgHint: $("bg-hint"), + bgSetup: $("bg-setup"), + bgSetupSize: $("bg-setup-size"), + bgDownload: $("bg-download"), + bgCancelSetup: $("bg-cancel-setup"), + bgProgress: $("bg-progress"), + bgProgressFill: $("bg-progress-fill"), + bgProgressText: $("bg-progress-text"), segThumb: document.querySelector(".segment-thumb"), }; @@ -30,6 +39,9 @@ const EXTENSIONS = ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff"]; /** path -> item {path,name,size,ext,supported,status,el,...} */ const items = new Map(); let converting = false; +/** Background-removal assets (runtime + model) present on disk. */ +let bgReady = false; +let bgInstalling = false; /* ---------- helpers ---------- */ @@ -70,6 +82,7 @@ function currentOptions() { format: currentFormat(), maxSizeKb: compressOn ? (Number.isFinite(kb) && kb > 0 ? kb : 500) : null, deleteOriginal: els.deleteOriginal.checked, + removeBackground: els.removeBg.checked && !els.removeBg.disabled, }; } @@ -80,6 +93,7 @@ function saveOptions() { compress: els.compress.checked, maxKb: els.maxKb.value, deleteOriginal: els.deleteOriginal.checked, + removeBg: els.removeBg.checked, })); } catch { /* best effort */ } } @@ -102,6 +116,71 @@ function syncCompressField() { els.maxKb.disabled = !els.compress.checked; } +/* ---------- background removal ---------- */ + +function syncBgControl() { + const jpeg = currentFormat() === "jpeg"; + els.removeBg.disabled = jpeg; + els.bgHint.hidden = !jpeg; + els.bgHint.textContent = jpeg ? "WEBP / PNG only" : ""; +} + +function showBgSetup(show) { + els.bgSetup.hidden = !show; + if (show) els.bgDownload.focus(); +} + +async function initBg() { + try { + const status = await invoke("bg_status"); + bgReady = status.ready; + els.bgSetupSize.textContent = `≈${fmtBytes(status.missingBytes)}`; + if (bgReady) { + const saved = JSON.parse(localStorage.getItem("options") || "null"); + if (saved?.removeBg) els.removeBg.checked = true; + } + } catch { /* feature stays off */ } + syncBgControl(); +} + +async function installBg() { + if (bgInstalling) return; + bgInstalling = true; + els.bgDownload.disabled = true; + els.bgDownload.textContent = "Downloading…"; + els.bgCancelSetup.hidden = true; + els.bgProgress.hidden = false; + + const onEvent = new Channel(); + onEvent.onmessage = (ev) => { + if (ev.type === "progress") { + const pct = ev.total ? Math.min(100, (ev.received / ev.total) * 100) : 0; + els.bgProgressFill.style.width = `${pct.toFixed(1)}%`; + els.bgProgressText.textContent = `${fmtBytes(ev.received)} / ${fmtBytes(ev.total)}`; + } + }; + + try { + await invoke("bg_install", { onEvent }); + bgReady = true; + showBgSetup(false); + els.removeBg.checked = true; + saveOptions(); + setStatus("Background removal ready", "good"); + els.removeBg.focus(); + } catch (e) { + els.bgProgressText.textContent = ""; + setStatus(`Setup failed: ${e}`, "bad"); + els.bgCancelSetup.hidden = false; + } finally { + bgInstalling = false; + els.bgDownload.disabled = false; + els.bgDownload.textContent = "Download"; + els.bgProgress.hidden = true; + els.bgProgressFill.style.width = "0%"; + } +} + function moveSegmentThumb() { const checked = document.querySelector('input[name="format"]:checked + label'); if (!checked) return; @@ -192,6 +271,12 @@ function cardTrail(item) { trail.innerHTML = ""; if (item.status === "done" && item.result) { + if (item.result.backgroundRemoved) { + const chip = document.createElement("span"); + chip.className = "badge chip"; + chip.textContent = "no bg"; + trail.appendChild(chip); + } const { inBytes, outBytes } = item.result; if (inBytes > 0 && outBytes != null) { const delta = 1 - outBytes / inBytes; @@ -263,6 +348,21 @@ function createCard(item, index) { renderCard(item); } +/// Show the actual cutout (with a checkerboard behind it) once done. +async function refreshOutputThumb(item) { + try { + const uri = await invoke("file_thumbnail", { path: item.result.outPath }); + const thumb = item.el?.querySelector(".thumb"); + if (!thumb) return; + thumb.classList.add("alpha"); + thumb.innerHTML = ""; + const img = document.createElement("img"); + img.alt = ""; + img.src = uri; + thumb.appendChild(img); + } catch { /* keep the source preview */ } +} + async function loadThumbnail(item) { if (!item.supported) return; try { @@ -366,6 +466,7 @@ async function convert() { item.message = ev.message || "failed"; } renderCard(item); + if (ev.ok && ev.backgroundRemoved) refreshOutputThumb(item); } else if (ev.type === "done") { finishBatch(ev, batch); } @@ -452,6 +553,7 @@ function wire() { document.querySelectorAll('input[name="format"]').forEach((radio) => radio.addEventListener("change", () => { moveSegmentThumb(); + syncBgControl(); saveOptions(); if (!converting) { const ready = readyItems().length; @@ -473,6 +575,24 @@ function wire() { }); els.deleteOriginal.addEventListener("change", saveOptions); + // Background removal. + els.removeBg.addEventListener("change", () => { + if (els.removeBg.checked && !bgReady) { + els.removeBg.checked = false; + showBgSetup(true); + return; + } + showBgSetup(false); + saveOptions(); + if (!converting) { + setStatus(els.removeBg.checked + ? "Background removal on — output gets a transparent background" + : `Mode: convert to ${currentFormat().toUpperCase()}`); + } + }); + els.bgDownload.addEventListener("click", installBg); + els.bgCancelSetup.addEventListener("click", () => showBgSetup(false)); + // Queue actions. els.clearAll.addEventListener("click", () => { if (converting) return; @@ -500,6 +620,7 @@ restoreOptions(); wire(); moveSegmentThumb(); setView(); +initBg(); setStatus(`Mode: convert to ${currentFormat().toUpperCase()}`); // Fonts load async; the thumb depends on final label widths. if (document.fonts?.ready) document.fonts.ready.then(moveSegmentThumb); diff --git a/ui/styles.css b/ui/styles.css index ffd2f79..1cac17c 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -536,6 +536,83 @@ fieldset.format-control legend { padding: 0; } .kb-unit { color: var(--text-faint); font-size: 10px; } +.bg-hint { font-size: 10px; color: var(--text-dim); } + +.switch:disabled + .switch-track { opacity: 0.4; cursor: not-allowed; } + +/* one-time model download panel */ +.bg-setup { + margin-top: 12px; + padding: 12px 14px; + border: 1px solid var(--lime-line); + border-radius: 10px; + background: var(--lime-soft); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.bg-setup-title { margin: 0; font-weight: 650; font-size: 13.5px; } + +.bg-setup-sub { + margin: 2px 0 0; + font-size: 12px; + color: var(--text-dim); + max-width: 52ch; +} + +.bg-setup-actions { display: flex; align-items: center; gap: 10px; } + +.setup-btn { + border: none; + border-radius: 8px; + padding: 8px 16px; + font-size: 13px; + font-weight: 650; + background: var(--lime); + color: #0d0d0d; + transition: filter 150ms, opacity 150ms; +} + +.setup-btn:hover:not(:disabled) { filter: brightness(1.07); } +.setup-btn:disabled { opacity: 0.55; cursor: default; } + +.bg-progress { + width: 150px; + height: 6px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.09); + overflow: hidden; +} + +.bg-progress-fill { + display: block; + height: 100%; + width: 0%; + background: var(--lime); + border-radius: 6px; + transition: width 200ms linear; +} + +.bg-progress-label { font-size: 10px; color: var(--text-dim); } + +/* checkerboard behind transparent results */ +.thumb.alpha { + background: + conic-gradient(rgba(255, 255, 255, 0.10) 0 25%, transparent 0 50%, + rgba(255, 255, 255, 0.10) 0 75%, transparent 0) 0 0 / 12px 12px, + var(--bg-raise); +} + +.badge.chip { + background: transparent; + border: 1px solid var(--lime-line); + color: var(--lime); + font-weight: 500; +} + .action-row { display: flex; align-items: center; From 6121bb7883b74f2255e1afc385b3f31782fa300f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Morel?= <137194052+LoicPandul@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:50:23 +0200 Subject: [PATCH 2/6] harden bg removal --- src-tauri/src/assets.rs | 68 ++++++++++++++++++++++++++++++-- src-tauri/src/commands.rs | 29 +++++++++++--- src-tauri/src/engine/compress.rs | 23 ++++++++++- src-tauri/src/engine/matting.rs | 64 +++++++++++++++++++++--------- src-tauri/src/engine/mod.rs | 23 ++++++++--- src-tauri/tests/engine.rs | 16 ++++++++ ui/main.js | 52 ++++++++++++++++++------ 7 files changed, 231 insertions(+), 44 deletions(-) diff --git a/src-tauri/src/assets.rs b/src-tauri/src/assets.rs index 1a409cf..a1a3cc7 100644 --- a/src-tauri/src/assets.rs +++ b/src-tauri/src/assets.rs @@ -22,10 +22,14 @@ const MODEL_SHA256: &str = "60920e99c45464f2ba57bee2ad08c919a52bbf852739e96947fb struct RemoteAsset { label: &'static str, url: String, + /// Hash of the downloaded bytes (archive or plain file). sha256: &'static str, /// Entry to pull out of the downloaded archive (None = plain file). archive_entry: Option, dest_name: &'static str, + /// Hash of the installed artifact, re-checked at load time so a torn + /// or tampered file in the user-writable dir is never dlopen'd/parsed. + payload_sha256: &'static str, download_bytes: u64, } @@ -37,6 +41,7 @@ fn runtime_asset() -> RemoteAsset { sha256: "174c616efc0271194488642a72f1a514e01487da4dfe84c49296d66e40ebe0da", archive_entry: Some("onnxruntime-win-x64-1.22.0/lib/onnxruntime.dll".into()), dest_name: "onnxruntime.dll", + payload_sha256: "579b636403983254346a5c1d80bd28f1519cd1e284cd204f8d4ff41f8d711559", download_bytes: 72_368_545, } } @@ -49,6 +54,7 @@ fn runtime_asset() -> RemoteAsset { sha256: "8344d55f93d5bc5021ce342db50f62079daf39aaafb5d311a451846228be49b3", archive_entry: Some("onnxruntime-linux-x64-1.22.0/lib/libonnxruntime.so.1.22.0".into()), dest_name: "libonnxruntime.so", + payload_sha256: "3da6146e14e7b8aaec625dde11d6114c7457c87a5f93d744897da8781e35c673", download_bytes: 7_798_730, } } @@ -63,6 +69,7 @@ fn runtime_asset() -> RemoteAsset { "onnxruntime-osx-universal2-1.22.0/lib/libonnxruntime.1.22.0.dylib".into(), ), dest_name: "libonnxruntime.dylib", + payload_sha256: "db045368293215c9d22aa7b8c983d688b3ae9ca1da3f64ffbe01ba7df31c3355", download_bytes: 54_820_264, } } @@ -74,6 +81,7 @@ fn model_asset() -> RemoteAsset { sha256: MODEL_SHA256, archive_entry: None, dest_name: "isnet-general-use.onnx", + payload_sha256: MODEL_SHA256, download_bytes: 178_648_008, } } @@ -132,6 +140,28 @@ impl BgAssets { } } + /// Re-hash the installed artifacts against the pins embedded in the + /// binary. A corrupted or tampered file is deleted so the UI offers the + /// download again — the repair path for torn installs (power loss) and + /// the guard against dlopen'ing a swapped library. + pub fn verify_installed(&self) -> Result<(), String> { + for asset in [runtime_asset(), model_asset()] { + let path = self.dir.join(asset.dest_name); + if !path.is_file() { + return Err(format!("{} is missing", asset.label)); + } + let actual = sha256_file(&path).map_err(|e| format!("{}: {e}", asset.label))?; + if !actual.eq_ignore_ascii_case(asset.payload_sha256) { + let _ = fs::remove_file(&path); + return Err(format!( + "{} failed integrity check and was removed — download it again", + asset.label + )); + } + } + Ok(()) + } + /// Download and install whatever is missing. `progress(received, total)` /// is called with byte counts across all pending downloads. pub fn install(&self, progress: impl Fn(u64, u64)) -> Result<(), String> { @@ -154,8 +184,14 @@ impl BgAssets { let staged = self.dir.join(format!("{}.download", asset.dest_name)); let dest = self.dir.join(asset.dest_name); - download_verified(&asset.url, asset.sha256, &staged, &progress) - .map_err(|e| format!("{}: {e}", asset.label))?; + download_verified( + &asset.url, + asset.sha256, + asset.download_bytes, + &staged, + &progress, + ) + .map_err(|e| format!("{}: {e}", asset.label))?; let result = match &asset.archive_entry { None => fs::rename(&staged, &dest).map_err(|e| format!("install: {e}")), @@ -175,10 +211,23 @@ impl BgAssets { } } -/// Stream the URL to `staged`, hashing on the fly; fail on sha mismatch. +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path).map_err(|e| format!("open: {e}"))?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).map_err(|e| format!("read: {e}"))?; + Ok(hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect()) +} + +/// Stream the URL to `staged`, hashing on the fly; fail on sha mismatch or +/// on a body larger than the pinned size. fn download_verified( url: &str, expected_sha256: &str, + expected_bytes: u64, staged: &Path, progress: &impl Fn(u64), ) -> Result<(), String> { @@ -207,8 +256,19 @@ fn download_verified( format!("write: {e}") })?; received += n as u64; + // The exact size is pinned along with the hash: a body that keeps + // streaming past it can only be wrong, stop before it fills the disk. + if received > expected_bytes { + let _ = fs::remove_file(staged); + return Err(format!( + "response larger than the expected {expected_bytes} bytes" + )); + } progress(received); } + // Flush to disk before the rename: a crash right after install must not + // leave a full-length torn file behind. + file.sync_all().map_err(|e| format!("write: {e}"))?; drop(file); let digest = hasher.finalize(); @@ -239,6 +299,7 @@ fn extract_entry(archive: &Path, entry: &str, dest: &Path) -> Result<(), String> .map_err(|e| format!("zip entry {entry}: {e}"))?; let mut out = fs::File::create(dest).map_err(|e| format!("extract: {e}"))?; std::io::copy(&mut wanted, &mut out).map_err(|e| format!("extract: {e}"))?; + out.sync_all().map_err(|e| format!("extract: {e}"))?; return Ok(()); } @@ -254,6 +315,7 @@ fn extract_entry(archive: &Path, entry: &str, dest: &Path) -> Result<(), String> if path == entry { let mut out = fs::File::create(dest).map_err(|e| format!("extract: {e}"))?; std::io::copy(&mut tar_entry, &mut out).map_err(|e| format!("extract: {e}"))?; + out.sync_all().map_err(|e| format!("extract: {e}"))?; return Ok(()); } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 71e608b..a497fb4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -39,6 +39,10 @@ impl BgState { if !self.assets.status().ready { return Err(engine::EngineError::MattingUnavailable.to_string()); } + // Never dlopen or parse an unverified file from the writable app + // dir: re-hash against the pins (a bad file is deleted so the UI + // offers the download again). + self.assets.verify_installed()?; engine::matting::init_runtime(&self.assets.dylib_path()).map_err(|e| e.to_string())?; let model = Matting::load(&self.assets.model_path()).map_err(|e| e.to_string())?; *guard = Some(Arc::new(model)); @@ -195,10 +199,7 @@ pub async fn convert_files( let succeeded = AtomicUsize::new(0); let failed = AtomicUsize::new(0); - paths.par_iter().for_each(|path| { - if cancel.load(Ordering::SeqCst) { - return; - } + let process_one = |path: &String| { let _ = on_event.send(ProgressEvent::Start { path: path.clone() }); let event = match engine::process_file_with(Path::new(path), &options, matting.as_deref()) { @@ -236,7 +237,25 @@ pub async fn convert_files( } }; let _ = on_event.send(event); - }); + }; + + if matting.is_some() { + // Inference serializes on the model session anyway; processing + // sequentially keeps Cancel responsive between files. + for path in &paths { + if cancel.load(Ordering::SeqCst) { + break; + } + process_one(path); + } + } else { + paths.par_iter().for_each(|path| { + if cancel.load(Ordering::SeqCst) { + return; + } + process_one(path); + }); + } let done = succeeded.load(Ordering::SeqCst) + failed.load(Ordering::SeqCst); let _ = on_event.send(ProgressEvent::Done { diff --git a/src-tauri/src/engine/compress.rs b/src-tauri/src/engine/compress.rs index 44406e1..c940993 100644 --- a/src-tauri/src/engine/compress.rs +++ b/src-tauri/src/engine/compress.rs @@ -41,7 +41,28 @@ fn fits(data: &[u8], target_bytes: u64) -> bool { fn resize(image: &DynamicImage, scale: f32) -> DynamicImage { let w = ((image.width() as f32 * scale) as u32).max(1); let h = ((image.height() as f32 * scale) as u32).max(1); - image.resize_exact(w, h, FilterType::Lanczos3) + if !image.color().has_alpha() { + return image.resize_exact(w, h, FilterType::Lanczos3); + } + // Straight-alpha resampling bleeds the RGB hidden under transparent + // pixels into the visible edges (background-colored halos on cutouts): + // premultiply, resize, unpremultiply. + let mut rgba = image.to_rgba8(); + for p in rgba.pixels_mut() { + let a = u16::from(p.0[3]); + for c in 0..3 { + p.0[c] = ((u16::from(p.0[c]) * a) / 255) as u8; + } + } + let mut resized = image::imageops::resize(&rgba, w, h, FilterType::Lanczos3); + for p in resized.pixels_mut() { + let a = u16::from(p.0[3]); + for c in 0..3 { + let unpremultiplied = (u16::from(p.0[c]) * 255).checked_div(a).unwrap_or(0); + p.0[c] = unpremultiplied.min(255) as u8; + } + } + DynamicImage::ImageRgba8(resized) } fn lossy_to_target( diff --git a/src-tauri/src/engine/matting.rs b/src-tauri/src/engine/matting.rs index c3d5f2b..cc62e03 100644 --- a/src-tauri/src/engine/matting.rs +++ b/src-tauri/src/engine/matting.rs @@ -20,19 +20,22 @@ const SIDE: u32 = 1024; const MEAN: [f32; 3] = [0.5, 0.5, 0.5]; const STD: [f32; 3] = [1.0, 1.0, 1.0]; -/// Point ort at the onnxruntime dynamic library. Process-wide and -/// idempotent: only the first call performs the initialization. +/// Point ort at the onnxruntime dynamic library. Process-wide; only the +/// first successful call initializes, and a failure can be retried (e.g. +/// after the library has been re-downloaded). pub fn init_runtime(dylib: &Path) -> Result<(), EngineError> { - static INIT: std::sync::OnceLock> = std::sync::OnceLock::new(); - let path = dylib.to_string_lossy().into_owned(); - INIT.get_or_init(|| { - ort::init_from(path) - .commit() - .map(|_| ()) - .map_err(|e| format!("onnxruntime init: {e}")) - }) - .clone() - .map_err(EngineError::Matting) + static DONE: Mutex = Mutex::new(false); + let mut done = DONE + .lock() + .map_err(|_| EngineError::Matting("runtime init state poisoned".into()))?; + if *done { + return Ok(()); + } + ort::init_from(dylib.to_string_lossy().into_owned()) + .commit() + .map_err(|e| EngineError::Matting(format!("onnxruntime init: {e}")))?; + *done = true; + Ok(()) } /// A loaded background-removal model. `matte` serializes calls: onnxruntime @@ -63,9 +66,22 @@ impl Matting { /// Soft alpha matte at the image's own resolution (0 = background, /// 255 = subject). pub fn matte(&self, image: &DynamicImage) -> Result { - let rgb = image - .resize_exact(SIDE, SIDE, FilterType::Triangle) - .to_rgb8(); + let resized = image.resize_exact(SIDE, SIDE, FilterType::Triangle); + // Transparent pixels carry arbitrary hidden RGB; composite over + // white so the model sees what a human sees. + let rgb = if resized.color().has_alpha() { + let rgba = resized.to_rgba8(); + let mut flat = image::RgbImage::new(SIDE, SIDE); + for (dst, src) in flat.pixels_mut().zip(rgba.pixels()) { + let a = u16::from(src.0[3]); + for c in 0..3 { + dst.0[c] = ((u16::from(src.0[c]) * a + 255 * (255 - a)) / 255) as u8; + } + } + flat + } else { + resized.to_rgb8() + }; let side = SIDE as usize; let mut input = Array4::::zeros((1, 3, side, side)); @@ -110,17 +126,27 @@ impl Matting { ))); } - // Min-max stretch, like the reference ISNet pipeline: the raw map is - // in [0, 1] but rarely spans it fully. let plane = &matte[..side * side]; let (mut lo, mut hi) = (f32::MAX, f32::MIN); for &v in plane { lo = lo.min(v); hi = hi.max(v); } - let range = (hi - lo).max(f32::EPSILON); + // No confident foreground anywhere: refuse instead of stretching + // model noise into an arbitrary cutout (and possibly replacing the + // user's file with it). + if hi < 0.5 { + return Err(EngineError::Matting("no subject detected".into())); + } + // Min-max stretch, like the reference ISNet pipeline — but only when + // the map actually spans a foreground/background split. A uniformly + // confident matte (frame-filling subject) is used raw so no hole + // gets punched through it. + let range = hi - lo; + let stretch = range >= 0.35; let small = GrayImage::from_fn(SIDE, SIDE, |x, y| { - let v = (matte[y as usize * side + x as usize] - lo) / range; + let raw = matte[y as usize * side + x as usize]; + let v = if stretch { (raw - lo) / range } else { raw }; Luma([(v.clamp(0.0, 1.0) * 255.0).round() as u8]) }); Ok(image::imageops::resize( diff --git a/src-tauri/src/engine/mod.rs b/src-tauri/src/engine/mod.rs index c329fb4..61debba 100644 --- a/src-tauri/src/engine/mod.rs +++ b/src-tauri/src/engine/mod.rs @@ -263,7 +263,14 @@ pub fn process_file_with( let delete_original = opts.delete_original && warning.is_none(); let out_bytes = data.len() as u64; - let plan = plan_output(path, &ext, opts.format, action, delete_original)?; + let plan = plan_output( + path, + &ext, + opts.format, + action, + delete_original, + background_removed, + )?; let (out_path, in_place) = match plan { OutputPlan::InPlace => (path.to_path_buf(), true), OutputPlan::Reserved(p) => (p, false), @@ -319,6 +326,7 @@ fn plan_output( format: TargetFormat, action: Action, delete_original: bool, + background_removed: bool, ) -> io::Result { let dir = path.parent().unwrap_or_else(|| Path::new("")); let stem = path.file_stem().and_then(OsStr::to_str).unwrap_or("image"); @@ -331,10 +339,15 @@ fn plan_output( let (stem, ext) = if delete_original { (stem.to_string(), format.extension()) } else { - let suffix = match action { - Action::Converted => "-converted", - Action::Compressed => "-compressed", - Action::Cleaned => "-clean", + // A cutout is named for what it is, whatever branch produced it. + let suffix = if background_removed { + "-nobg" + } else { + match action { + Action::Converted => "-converted", + Action::Compressed => "-compressed", + Action::Cleaned => "-clean", + } }; let ext = if action != Action::Converted && extension_is_right { orig_ext diff --git a/src-tauri/tests/engine.rs b/src-tauri/tests/engine.rs index 0421c23..9474c81 100644 --- a/src-tauri/tests/engine.rs +++ b/src-tauri/tests/engine.rs @@ -483,6 +483,22 @@ fn real_model_cuts_white_background() { out.get_pixel(320, 240).0[3] > 200, "subject must stay opaque" ); + assert!( + outcome.out_path.to_string_lossy().ends_with("-nobg.png"), + "cutouts kept next to the original are named -nobg" + ); + + // A subject-less image must be refused, never stretched into a noise + // cutout that could replace the user's file. + let flat = DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 512, + 512, + image::Rgb([230, 231, 233]), + )); + assert!( + matting.matte(&flat).is_err(), + "uniform image must yield 'no subject detected'" + ); } // ---------- compression ---------- diff --git a/ui/main.js b/ui/main.js index e7146b1..d2160c5 100644 --- a/ui/main.js +++ b/ui/main.js @@ -123,6 +123,7 @@ function syncBgControl() { els.removeBg.disabled = jpeg; els.bgHint.hidden = !jpeg; els.bgHint.textContent = jpeg ? "WEBP / PNG only" : ""; + if (jpeg) showBgSetup(false); } function showBgSetup(show) { @@ -131,11 +132,12 @@ function showBgSetup(show) { } async function initBg() { + els.bgSetupSize.textContent = "≈250 MB"; try { const status = await invoke("bg_status"); bgReady = status.ready; - els.bgSetupSize.textContent = `≈${fmtBytes(status.missingBytes)}`; - if (bgReady) { + if (!bgReady) els.bgSetupSize.textContent = `≈${fmtBytes(status.missingBytes)}`; + if (bgReady && !els.removeBg.disabled) { const saved = JSON.parse(localStorage.getItem("options") || "null"); if (saved?.removeBg) els.removeBg.checked = true; } @@ -164,14 +166,20 @@ async function installBg() { await invoke("bg_install", { onEvent }); bgReady = true; showBgSetup(false); - els.removeBg.checked = true; - saveOptions(); - setStatus("Background removal ready", "good"); - els.removeBg.focus(); + if (!els.removeBg.disabled) { + els.removeBg.checked = true; + saveOptions(); + els.removeBg.focus(); + } else { + els.browse.focus(); + } + if (!converting) setStatus("Background removal ready", "good"); } catch (e) { - els.bgProgressText.textContent = ""; - setStatus(`Setup failed: ${e}`, "bad"); + // The error lives in the panel: the status line may be owned by a + // running conversion. + els.bgProgressText.textContent = `failed: ${e}`; els.bgCancelSetup.hidden = false; + if (!converting) setStatus("Background removal setup failed", "bad"); } finally { bgInstalling = false; els.bgDownload.disabled = false; @@ -350,8 +358,12 @@ function createCard(item, index) { /// Show the actual cutout (with a checkerboard behind it) once done. async function refreshOutputThumb(item) { + const requested = item.result?.outPath; + if (!requested) return; try { - const uri = await invoke("file_thumbnail", { path: item.result.outPath }); + const uri = await invoke("file_thumbnail", { path: requested }); + // A newer conversion may have landed while the thumbnail was loading. + if (item.result?.outPath !== requested) return; const thumb = item.el?.querySelector(".thumb"); if (!thumb) return; thumb.classList.add("alpha"); @@ -396,7 +408,10 @@ async function addFiles(paths) { if (existing.status !== "converting") { existing.status = existing.supported ? "ready" : "error"; existing.size = info.size; + existing.result = null; + existing.el?.querySelector(".thumb")?.classList.remove("alpha"); renderCard(existing); + loadThumbnail(existing); } continue; } @@ -466,7 +481,11 @@ async function convert() { item.message = ev.message || "failed"; } renderCard(item); - if (ev.ok && ev.backgroundRemoved) refreshOutputThumb(item); + if (ev.ok && ev.backgroundRemoved) { + refreshOutputThumb(item); + } else { + item.el?.querySelector(".thumb")?.classList.remove("alpha"); + } } else if (ev.type === "done") { finishBatch(ev, batch); } @@ -486,6 +505,9 @@ async function convert() { if (i.status === "converting") { i.status = "ready"; renderCard(i); } }); refreshAction(); + // A failed integrity check removes assets server-side: re-sync so the + // toggle offers the download again. + initBg(); } } @@ -579,6 +601,10 @@ function wire() { els.removeBg.addEventListener("change", () => { if (els.removeBg.checked && !bgReady) { els.removeBg.checked = false; + if (converting) { + setStatus("Finish the current batch before setting up background removal"); + return; + } showBgSetup(true); return; } @@ -591,7 +617,10 @@ function wire() { } }); els.bgDownload.addEventListener("click", installBg); - els.bgCancelSetup.addEventListener("click", () => showBgSetup(false)); + els.bgCancelSetup.addEventListener("click", () => { + showBgSetup(false); + els.removeBg.focus(); + }); // Queue actions. els.clearAll.addEventListener("click", () => { @@ -619,6 +648,7 @@ function wire() { restoreOptions(); wire(); moveSegmentThumb(); +syncBgControl(); setView(); initBg(); setStatus(`Mode: convert to ${currentFormat().toUpperCase()}`); From 13f0900ec6836af78ade36b0131a515bda704219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Morel?= <137194052+LoicPandul@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:24:16 +0200 Subject: [PATCH 3/6] update README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bc35a62..b94a9f1 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ Grab the latest installer from the [Releases](https://github.com/LoicPandul/Imag - The ICC color profile is deliberately kept. It contains no personal information (it is a generic file shipped with your camera or screen), and removing it would visibly shift the colors of wide-gamut images. - To guarantee a precise weight, give a maximum size in KB: the app searches for the best quality that fits, and only downscales as a last resort. Lossy PNG relies on built-in palette quantization, so there is no external tool to install. - The EXIF orientation is applied before the metadata is stripped, so rotated phone photos come out upright. +- Optional background removal, in the spirit of remove.bg but on your machine: an AI model (ISNet) cuts the subject out and the background becomes transparent, for WEBP and PNG targets. Off by default; the first activation downloads the model and its runtime once (~250 MB, checksum-verified), then it runs fully offline. Images never leave your computer. - Every file is processed on its own CPU core. - Existing files are never overwritten (a numbered suffix is added instead), and an original is only deleted once its replacement is fully written. -- Native on Windows, macOS and Linux: a few MB, instant startup, zero network access. +- Native on Windows, macOS and Linux: a few MB, instant startup. The app never touches the network, with one exception: the explicit background-removal download above. ## Build from source From 9c6e332b14412b00676f7e067c3a731a7d07034c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Morel?= <137194052+LoicPandul@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:33:43 +0200 Subject: [PATCH 4/6] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b94a9f1..0151796 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Grab the latest installer from the [Releases](https://github.com/LoicPandul/Imag - The ICC color profile is deliberately kept. It contains no personal information (it is a generic file shipped with your camera or screen), and removing it would visibly shift the colors of wide-gamut images. - To guarantee a precise weight, give a maximum size in KB: the app searches for the best quality that fits, and only downscales as a last resort. Lossy PNG relies on built-in palette quantization, so there is no external tool to install. - The EXIF orientation is applied before the metadata is stripped, so rotated phone photos come out upright. -- Optional background removal, in the spirit of remove.bg but on your machine: an AI model (ISNet) cuts the subject out and the background becomes transparent, for WEBP and PNG targets. Off by default; the first activation downloads the model and its runtime once (~250 MB, checksum-verified), then it runs fully offline. Images never leave your computer. +- Optional background removal on your machine: an AI model (ISNet) cuts the subject out and the background becomes transparent, for WEBP and PNG targets. Off by default; the first activation downloads the model and its runtime once (~250 MB, checksum-verified), then it runs fully offline. Images never leave your computer. - Every file is processed on its own CPU core. - Existing files are never overwritten (a numbered suffix is added instead), and an original is only deleted once its replacement is fully written. - Native on Windows, macOS and Linux: a few MB, instant startup. The app never touches the network, with one exception: the explicit background-removal download above. From 94ea6ac18a12512331f8ab1c2c8ec14c116970fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Morel?= <137194052+LoicPandul@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:44:48 +0200 Subject: [PATCH 5/6] release signing --- .github/workflows/release.yml | 14 +++++++++++++ README.md | 38 +++++++++++++++++++++++++++++++++++ scripts/sign-release.ps1 | 36 +++++++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 1 + 4 files changed, 89 insertions(+) create mode 100644 scripts/sign-release.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 059096c..5bae371 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,20 @@ jobs: - **Windows**: `*-setup.exe` (installer) or `.msi` - **macOS**: `.dmg` — unsigned build: on first launch, right-click the app → Open - **Linux**: `.AppImage` (portable, `chmod +x` then run), `.deb` or `.rpm` + + ### Verify your download (optional) + `SHA256SUMS` lists the hash of every file above and is signed with the author's [minisign](https://jedisct1.github.io/minisign/) key: + + ``` + RWTz3c4gUmglCX5Uvjthigz1ts3TS3ZSdhRNpFgOJRW/Wr4XjGlqTR3O + ``` + + 1. Signature — proves the hash list comes from the author: + `minisign -Vm SHA256SUMS -P RWTz3c4gUmglCX5Uvjthigz1ts3TS3ZSdhRNpFgOJRW/Wr4XjGlqTR3O` + 2. Hashes — proves your file was not altered: + - Linux: `sha256sum --check SHA256SUMS --ignore-missing` + - macOS: `shasum -a 256 --check SHA256SUMS --ignore-missing` + - Windows: `(Get-FileHash .\).Hash` must match the file's line in `SHA256SUMS` releaseDraft: true prerelease: false args: ${{ matrix.args }} diff --git a/README.md b/README.md index 0151796..e50620e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,44 @@ Grab the latest installer from the [Releases](https://github.com/LoicPandul/Imag - Existing files are never overwritten (a numbered suffix is added instead), and an original is only deleted once its replacement is fully written. - Native on Windows, macOS and Linux: a few MB, instant startup. The app never touches the network, with one exception: the explicit background-removal download above. +## Verify your download + +Each release ships a `SHA256SUMS` manifest signed with the author's [minisign](https://jedisct1.github.io/minisign/) key. The public key is: + +``` +RWTz3c4gUmglCX5Uvjthigz1ts3TS3ZSdhRNpFgOJRW/Wr4XjGlqTR3O +``` + +Download `SHA256SUMS` and `SHA256SUMS.minisig` into the same folder as your installer, then run the two checks for your platform: the signature proves the hash list comes from the author, the hash proves your file was not altered. + +### Windows (PowerShell) + +Get `minisign.exe` from the [official releases](https://github.com/jedisct1/minisign/releases) (win64 zip, `x86_64` folder). + +```powershell +minisign -Vm SHA256SUMS -P RWTz3c4gUmglCX5Uvjthigz1ts3TS3ZSdhRNpFgOJRW/Wr4XjGlqTR3O + +$file = "ImagesConverter_2.1.0_x64-setup.exe" # the file you downloaded +$hash = (Get-FileHash $file).Hash.ToLower() +if (Select-String -Quiet -SimpleMatch "$hash $file" SHA256SUMS) { "OK: $file matches" } else { "MISMATCH - do not run this file" } +``` + +### macOS + +```sh +brew install minisign +minisign -Vm SHA256SUMS -P RWTz3c4gUmglCX5Uvjthigz1ts3TS3ZSdhRNpFgOJRW/Wr4XjGlqTR3O +shasum -a 256 --check SHA256SUMS --ignore-missing +``` + +### Linux + +```sh +sudo apt install minisign # or your distribution's equivalent +minisign -Vm SHA256SUMS -P RWTz3c4gUmglCX5Uvjthigz1ts3TS3ZSdhRNpFgOJRW/Wr4XjGlqTR3O +sha256sum --check SHA256SUMS --ignore-missing +``` + ## Build from source Requires [Rust](https://rustup.rs/). diff --git a/scripts/sign-release.ps1 b/scripts/sign-release.ps1 new file mode 100644 index 0000000..7a2b155 --- /dev/null +++ b/scripts/sign-release.ps1 @@ -0,0 +1,36 @@ +# Sign a release with minisign: download its artifacts, hash them into a +# SHA256SUMS manifest, sign the manifest, upload both files back. +# The secret key never leaves this machine — CI only ever builds. +# +# Usage: pwsh scripts/sign-release.ps1 v2.1.0 +# (works on the draft release before you click Publish) + +param([Parameter(Mandatory)][string]$Tag) +$ErrorActionPreference = "Stop" + +if (-not (Get-Command minisign -ErrorAction SilentlyContinue)) { + throw "minisign not found - install it first (https://jedisct1.github.io/minisign/)" +} + +$dir = Join-Path ([System.IO.Path]::GetTempPath()) "imagesconverter-sign-$Tag" +if (Test-Path $dir) { Remove-Item -Recurse -Force $dir } +New-Item -ItemType Directory $dir | Out-Null + +Write-Output "downloading $Tag artifacts..." +gh release download $Tag --dir $dir + +# sha256sum -c compatible manifest: " ", sorted, lowercase. +$files = Get-ChildItem $dir -File | Where-Object { $_.Name -notlike "SHA256SUMS*" } | Sort-Object Name +$manifest = ($files | ForEach-Object { + "{0} {1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower(), $_.Name +}) -join "`n" +$sums = Join-Path $dir "SHA256SUMS" +[System.IO.File]::WriteAllText($sums, $manifest + "`n") + +Write-Output "signing (minisign will ask for your key password)..." +minisign -Sm $sums -t "ImagesConverter $Tag" +if ($LASTEXITCODE -ne 0) { throw "minisign failed" } + +gh release upload $Tag $sums "$sums.minisig" --clobber +Write-Output "done: SHA256SUMS + SHA256SUMS.minisig attached to $Tag." +Write-Output "review the draft on GitHub, then click Publish." diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 03c16bc..6ce3202 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,6 +30,7 @@ "active": true, "targets": "all", "category": "Utility", + "publisher": "Loïc Morel", "icon": [ "icons/32x32.png", "icons/128x128.png", From 2fb1bf26095dd6da9acc565f6094f3d13dc615de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Morel?= <137194052+LoicPandul@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:07:30 +0200 Subject: [PATCH 6/6] v2.1.0 --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 86c2992..69a4d10 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1816,7 +1816,7 @@ dependencies = [ [[package]] name = "imagesconverter" -version = "2.0.0" +version = "2.1.0" dependencies = [ "base64 0.22.1", "crc32fast", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9180c1e..024ef21 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imagesconverter" -version = "2.0.0" +version = "2.1.0" description = "Convert, compress and clean metadata from images" authors = ["Loïc Morel"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6ce3202..230a161 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ImagesConverter", - "version": "2.0.0", + "version": "2.1.0", "identifier": "org.pandul.imagesconverter", "build": { "frontendDist": "../ui"