Browse / github.com/storytold/artcraft / patch
artcraft-native: add the BG Remove page, running a small model locally
artcraft-native: add the BG Remove page, running a small model locally BG Remove was one of the "coming soon" tools in artcraft-native. This implements it, and it runs entirely on the user's machine: no upload, no account, no credits. - Choose a picture (png, jpg, jpeg, webp) and see the original next to the result on a checkerboard - Save the result as a PNG at the original size - The model is U2-Netp (4.5 MB, Apache-2.0, the same file rembg ships), run through `tract-onnx`. About a second per picture on a CPU in a release build. - On first use the model is downloaded once to `~/Artcraft/models/u2netp.onnx`, written to a temporary name first and checked against
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- devstivi (imported from pull request #2577, open)
- Base
1f969a9224934a55417daf10359a6fb1458b63f4- Size
- +896 −26 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
03b9d897922d1065ca5995b7
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock artcraft-native/Cargo.toml artcraft-native/src/app.rs artcraft-native/src/backend/background.rs artcraft-native/src/backend/mod.rs artcraft-native/src/pages/mod.rs artcraft-native/src/pages/remove_background.rs artcraft-native/src/shell.rs
@@ -313,6 +313,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]]+name = "anymap3"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9"+ [[package]] name = "arbitrary" version = "1.4.2"@@ -509,9 +515,11 @@ dependencies = [ "rodio", "serde", "serde_json",+ "sha2 0.11.0-rc.5", "tempfile", "tokens", "tokio",+ "tract-onnx", "url", "uuid 1.22.0", "wry",@@ -843,7 +851,7 @@ dependencies = [ "anyhow", "arrayvec", "log",- "nom",+ "nom 7.1.3", "num-rational", "v_frame", ]@@ -933,6 +941,15 @@ dependencies = [ "bit-vec 0.9.1", ] +[[package]]+name = "bit-set"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"+dependencies = [+ "bit-vec 0.10.1",+]+ [[package]] name = "bit-vec" version = "0.8.0"@@ -945,6 +962,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]]+name = "bit-vec"+version = "0.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"+dependencies = [+ "borsh",+ "serde",+]+ [[package]] name = "bit_field" version = "0.10.2"@@ -1060,25 +1087,26 @@ dependencies = [ [[package]] name = "borsh"-version = "1.5.5"+version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "5430e3be710b68d984d1391c854eb431a9d548640711faa54eecb1df93db91cc"+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ "borsh-derive",+ "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive"-version = "1.5.5"+version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f8b668d39970baad5356d7c83a86fee3a539e6f93bf6764c97368243e17a0487"+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" dependencies = [ "once_cell", "proc-macro-crate 3.5.0", "proc-macro2", "quote",- "syn 2.0.117",+ "syn 3.0.7", ] [[package]]@@ -1421,7 +1449,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [- "nom",+ "nom 7.1.3", ] [[package]]@@ -1466,6 +1494,17 @@ dependencies = [ "libc", ] +[[package]]+name = "chacha20"+version = "0.10.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"+dependencies = [+ "cfg-if",+ "cpufeatures 0.3.1",+ "rand_core 0.10.1",+]+ [[package]] name = "chrono" version = "0.4.44"@@ -1812,6 +1851,15 @@ dependencies = [ "libc", ] +[[package]]+name = "cpufeatures"+version = "0.3.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"+dependencies = [+ "libc",+]+ [[package]] name = "crc" version = "3.0.1"@@ -2053,6 +2101,17 @@ dependencies = [ "serde", ] +[[package]]+name = "derive-new"+version = "0.7.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.117",+]+ [[package]] name = "derive_more" version = "2.0.1"@@ -2266,6 +2325,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]]+name = "downcast-rs"+version = "2.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"+ [[package]] name = "dpi" version = "0.1.1"@@ -2317,6 +2382,12 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" +[[package]]+name = "dyn-hash"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5fdab65db9274e0168143841eb8f864a0a21f8b1b8d2ba6812bbe6024346e99e"+ [[package]] name = "ecolor" version = "0.36.2"@@ -2862,6 +2933,16 @@ dependencies = [ "walkdir", ] +[[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"@@ -2890,6 +2971,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]]+name = "float-ord"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"+ [[package]] name = "flume" version = "0.11.0"@@ -2898,7 +2985,7 @@ checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" dependencies = [ "futures-core", "futures-sink",- "spin 0.9.8",+ "spin", ] [[package]]@@ -3326,6 +3413,7 @@ dependencies = [ "cfg-if", "libc", "r-efi",+ "rand_core 0.10.1", "wasip2", "wasip3", ]@@ -4327,6 +4415,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]]+name = "inventory"+version = "0.3.25"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c"+dependencies = [+ "rustversion",+]+ [[package]] name = "ipnet" version = "2.11.0"@@ -4594,7 +4691,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [- "cpufeatures",+ "cpufeatures 0.2.12", ] [[package]]@@ -4671,11 +4768,11 @@ dependencies = [ [[package]] name = "lazy_static"-version = "1.4.0"+version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" dependencies = [- "spin 0.5.2",+ "spin", ] [[package]]@@ -4926,6 +5023,12 @@ dependencies = [ "libc", ] +[[package]]+name = "maplit"+version = "1.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"+ [[package]] name = "markup5ever" version = "0.35.0"@@ -4959,6 +5062,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]]+name = "matrixmultiply"+version = "0.3.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"+dependencies = [+ "autocfg",+ "rawpointer",+]+ [[package]] name = "maybe-rayon" version = "0.1.1"@@ -4994,6 +5107,12 @@ dependencies = [ "libc", ] +[[package]]+name = "memo-map"+version = "0.3.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5449c8c750f1a07ea702bbd212bd999fceece9b3d1508b17023b3e174583124b"+ [[package]] name = "memoffset" version = "0.9.0"@@ -5011,6 +5130,16 @@ dependencies = [ "errors", ] +[[package]]+name = "metrics"+version = "0.24.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2"+dependencies = [+ "portable-atomic",+ "rapidhash",+]+ [[package]] name = "midjourney_client" version = "0.0.1"@@ -5065,6 +5194,16 @@ dependencies = [ "test_utils", ] +[[package]]+name = "minijinja"+version = "2.24.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "86886cf6dbf4e614b19c9a1eec9775f021869d7eadde0fc73921a81b90c9b4c9"+dependencies = [+ "memo-map",+ "serde",+]+ [[package]] name = "minimal-lexical" version = "0.2.1"@@ -5174,6 +5313,21 @@ dependencies = [ "tempfile", ] +[[package]]+name = "ndarray"+version = "0.17.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"+dependencies = [+ "matrixmultiply",+ "num-complex",+ "num-integer",+ "num-traits",+ "portable-atomic",+ "portable-atomic-util",+ "rawpointer",+]+ [[package]] name = "ndk" version = "0.8.0"@@ -5249,6 +5403,24 @@ dependencies = [ "minimal-lexical", ] +[[package]]+name = "nom"+version = "8.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"+dependencies = [+ "memchr",+]++[[package]]+name = "nom-language"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29"+dependencies = [+ "nom 8.0.0",+]+ [[package]] name = "noop_proc_macro" version = "0.3.0"@@ -5282,6 +5454,15 @@ dependencies = [ "zeroize", ] +[[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.1.0"@@ -6004,6 +6185,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]]+name = "pastey"+version = "0.2.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"+ [[package]] name = "pathdiff" version = "0.2.3"@@ -6340,6 +6527,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]]+name = "primal-check"+version = "0.3.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"+dependencies = [+ "num-integer",+]+ [[package]] name = "primitives" version = "0.0.1"@@ -6450,6 +6646,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]]+name = "prost"+version = "0.14.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"+dependencies = [+ "bytes",+ "prost-derive",+]++[[package]]+name = "prost-derive"+version = "0.14.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"+dependencies = [+ "anyhow",+ "itertools 0.13.0",+ "proc-macro2",+ "quote",+ "syn 2.0.117",+]+ [[package]] name = "psl-types" version = "2.0.11"@@ -6628,6 +6847,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]]+name = "rand"+version = "0.10.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"+dependencies = [+ "chacha20",+ "getrandom 0.4.1",+ "rand_core 0.10.1",+]+ [[package]] name = "rand_chacha" version = "0.3.1"@@ -6681,6 +6911,31 @@ dependencies = [ "getrandom 0.3.1", ] +[[package]]+name = "rand_core"+version = "0.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"++[[package]]+name = "rand_distr"+version = "0.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"+dependencies = [+ "num-traits",+ "rand 0.10.3",+]++[[package]]+name = "rapidhash"+version = "4.5.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e"+dependencies = [+ "rustversion",+]+ [[package]] name = "rav1e" version = "0.7.1"@@ -6737,6 +6992,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.10.0"@@ -7028,7 +7289,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.14", "libc",- "spin 0.9.8",+ "spin", "untrusted", "windows-sys 0.52.0", ]@@ -7149,6 +7410,20 @@ dependencies = [ "semver", ] +[[package]]+name = "rustfft"+version = "6.4.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"+dependencies = [+ "num-complex",+ "num-integer",+ "num-traits",+ "primal-check",+ "strength_reduce",+ "transpose",+]+ [[package]] name = "rustix" version = "0.38.44"@@ -7737,7 +8012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if",- "cpufeatures",+ "cpufeatures 0.2.12", "digest 0.10.7", ] @@ -7748,7 +8023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if",- "cpufeatures",+ "cpufeatures 0.2.12", "digest 0.10.7", ] @@ -7759,7 +8034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" dependencies = [ "cfg-if",- "cpufeatures",+ "cpufeatures 0.2.12", "digest 0.11.2", ] @@ -8022,12 +8297,6 @@ dependencies = [ "system-deps", ] -[[package]]-name = "spin"-version = "0.5.2"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"- [[package]] name = "spin" version = "0.9.8"@@ -8054,7 +8323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85" dependencies = [ "itertools 0.11.0",- "nom",+ "nom 7.1.3", "unicode_categories", ] @@ -8290,6 +8559,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]]+name = "strength_reduce"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"+ [[package]] name = "strict-num" version = "0.1.1"@@ -8299,6 +8574,16 @@ dependencies = [ "float-cmp", ] +[[package]]+name = "string-interner"+version = "0.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ad3df9b59e2eded8d825c7c4363ad339a20fb6bc0b9a4778560f518f59910b15"+dependencies = [+ "hashbrown 0.16.1",+ "serde",+]+ [[package]] name = "string_cache" version = "0.8.9"@@ -8702,6 +8987,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[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.14"@@ -9577,6 +9873,185 @@ dependencies = [ "once_cell", ] +[[package]]+name = "tract-core"+version = "0.23.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bb76580817f14ceb9f054d3732c1d09575940e88fa5bbfc85c3eae34482bb089"+dependencies = [+ "anyhow",+ "anymap3",+ "bit-set 0.11.1",+ "derive-new",+ "downcast-rs 2.0.2",+ "dyn-clone",+ "erased-serde",+ "inventory",+ "lazy_static",+ "log",+ "maplit",+ "metrics",+ "ndarray",+ "num-complex",+ "num-integer",+ "num-traits",+ "pastey",+ "rustfft",+ "serde",+ "smallvec",+ "tract-data",+ "tract-linalg",+]++[[package]]+name = "tract-data"+version = "0.23.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "27082b8906ef417d17a7da29beb6d9b42fdf5bb22bd83b7007f43039747a5121"+dependencies = [+ "anyhow",+ "downcast-rs 2.0.2",+ "dyn-clone",+ "dyn-hash",+ "half",+ "inventory",+ "itertools 0.15.0",+ "lazy_static",+ "libm",+ "maplit",+ "ndarray",+ "nom 8.0.0",+ "nom-language",+ "num-integer",+ "num-traits",+ "parking_lot",+ "smallvec",+ "string-interner",+]++[[package]]+name = "tract-extra"+version = "0.23.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40e75f452c7631a5f7b9993bb1a56895c3dc99125e9d14b2036430b1c621f01e"+dependencies = [+ "tract-nnef",+]++[[package]]+name = "tract-hir"+version = "0.23.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc9b23e03b965c23ff4362b8cd062f21ccce2eeed40cb28a7f3715e8facf2984"+dependencies = [+ "derive-new",+ "log",+ "tract-core",+]++[[package]]+name = "tract-linalg"+version = "0.23.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ca57ed3a2f6293e7866c6ceed7c6ca3413b4e083a892a00a79f5e9b607154982"+dependencies = [+ "byteorder",+ "cc",+ "derive-new",+ "downcast-rs 2.0.2",+ "dyn-clone",+ "dyn-hash",+ "half",+ "inventory",+ "lazy_static",+ "libc",+ "log",+ "minijinja",+ "num-traits",+ "pastey",+ "tract-data",+ "walkdir",+]++[[package]]+name = "tract-nnef"+version = "0.23.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3bdfc2f17d10eaf04d27f7a2d7b45e3dc41ffd0bc6c1e48d87636d7e960d441d"+dependencies = [+ "byteorder",+ "erased-serde",+ "log",+ "nom 8.0.0",+ "nom-language",+ "serde",+ "serde_json",+ "simd-adler32",+ "tar",+ "tract-core",+ "walkdir",+]++[[package]]+name = "tract-onnx"+version = "0.23.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "43727ce75fcd18908927e6e1b47d043fe5606d8b85c8ea6aa36481406d37b24a"+dependencies = [+ "bytes",+ "derive-new",+ "log",+ "memmap2",+ "nom 8.0.0",+ "nom-language",+ "num-integer",+ "num-rational",+ "num-traits",+ "prost",+ "smallvec",+ "tract-extra",+ "tract-hir",+ "tract-nnef",+ "tract-onnx-opl",+ "tract-transformers",+]++[[package]]+name = "tract-onnx-opl"+version = "0.23.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "080b33bcc1eb78a972cfb0f974acb7ce046901d9161f7a4a0369b88efa44b599"+dependencies = [+ "getrandom 0.4.1",+ "log",+ "rand 0.10.3",+ "rand_distr",+ "rustfft",+ "tract-extra",+ "tract-nnef",+]++[[package]]+name = "tract-transformers"+version = "0.23.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "093f55e4086a09a241f3b379f8ebcfd9070c7dee208bb2d8faa4f4fcf75b6b39"+dependencies = [+ "float-ord",+ "rayon",+ "tract-nnef",+]++[[package]]+name = "transpose"+version = "0.2.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"+dependencies = [+ "num-integer",+ "strength_reduce",+]+ [[package]] name = "tray-icon" version = "0.23.1"@@ -10258,7 +10733,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc",- "downcast-rs",+ "downcast-rs 1.2.1", "rustix 1.1.5", "scoped-tls", "smallvec",@@ -11700,6 +12175,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]]+name = "xattr"+version = "1.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"+dependencies = [+ "libc",+ "rustix 1.1.5",+]+ [[package]] name = "xcursor" version = "0.3.11"@@ -35,6 +35,8 @@ open = "5" reqwest = { workspace = true, features = ["multipart"] } resvg = "0.45" rfd = "0.16"+sha2.workspace = true+tract-onnx = "0.23" rodio = { version = "0.20", default-features = false, features = ["symphonia-all"] } serde.workspace = true serde_json.workspace = true@@ -24,6 +24,7 @@ use crate::overlays::lightbox::{Lightbox, LightboxAction, fit_inside}; use crate::overlays::providers::{AccountAction, AccountsView, LoginEvent, MidjourneyLogin, SetupAction, setup_dialog}; use crate::pages::common::{self, AppRequest, Env, FeedKey}; use crate::pages::create_audio::{AudioSettings, CreateAudioPage};+use crate::pages::remove_background::RemoveBackgroundPage; use crate::pages::create_image::{CreateImagePage, ImageSettings}; use crate::pages::create_video::{CreateVideoPage, VideoSettings}; use crate::pages::other;@@ -86,6 +87,7 @@ pub struct ArtcraftApp { image: CreateImagePage, video: CreateVideoPage, audio: CreateAudioPage,+ background: RemoveBackgroundPage, library: FeedStore, ratios: RatioCache, /// Prompt texts by prompt token (list view, lightbox).@@ -132,7 +134,7 @@ impl ArtcraftApp { backend.load_models(modality); } backend.refresh_session();- let mut app = Self { cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, provider_setup: None, midjourney_login: None, fal_key_input: String::new(), preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend };+ let mut app = Self { cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), background: RemoveBackgroundPage::default(), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, provider_setup: None, midjourney_login: None, fal_key_input: String::new(), preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }; app.restore_provider_jobs(); // Diagnostics: `ARTCRAFT_OPEN=settings` or `midjourney-login` opens that dialog at launch. match std::env::var("ARTCRAFT_OPEN").as_deref() {@@ -634,6 +636,7 @@ impl ArtcraftApp { Page::CreateImage => self.image.ui(ui, &mut env), Page::CreateVideo => self.video.ui(ui, &mut env), Page::CreateAudio => self.audio.ui(ui, &mut env),+ Page::RemoveBackground => self.background.ui(ui, &mut env), Page::Library => other::library(ui, &mut env, &mut self.library), page => other::coming_soon(ui, page), }new file mode 100644@@ -0,0 +1,172 @@+//! Removing an image's background on this machine. A small salient-object model (U²-Netp, 4.5 MB,+//! Apache-2.0) runs through `tract`, so nothing is uploaded and no account or credits are needed.+//! The model is downloaded once into `~/Artcraft/models` and checked against a known hash.++use std::path::{Path, PathBuf};+use std::sync::mpsc::{channel, Receiver};++use image::imageops::FilterType;+use image::{GrayImage, Luma, RgbaImage};+use sha2::{Digest, Sha256};+use tokio::runtime::Handle;++use crate::backend::session;++const MODEL_URL: &str = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx";+const MODEL_SHA256: &str = "309c8469258dda742793dce0ebea8e6dd393174f89934733ecc8b14c76f4ddd8";+const MODEL_FILE: &str = "u2netp.onnx";+/// The model looks at the picture squeezed into a square of this many pixels.+const MODEL_SIDE: u32 = 320;+const MEAN: [f32; 3] = [0.485, 0.456, 0.406];+const STD: [f32; 3] = [0.229, 0.224, 0.225];++/// What the worker is doing right now.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Stage {+ DownloadingModel,+ Removing,+}++pub enum Progress {+ Stage(Stage),+ Done(Result<RgbaImage, String>),+}++/// Removes the background of `image` on a worker thread; `ctx` wakes the UI on every update.+pub fn spawn(rt: Handle, ctx: egui::Context, image: RgbaImage) -> Receiver<Progress> {+ let (tx, rx) = channel();+ std::thread::spawn(move || {+ let send = |progress| {+ let _ = tx.send(progress);+ ctx.request_repaint();+ };+ let result = run(&rt, &image, &send);+ send(Progress::Done(result));+ });+ rx+}++fn run(rt: &Handle, image: &RgbaImage, send: &dyn Fn(Progress)) -> Result<RgbaImage, String> {+ let model = model_path();+ if !model.exists() {+ send(Progress::Stage(Stage::DownloadingModel));+ rt.block_on(download_model(&model))?;+ }+ send(Progress::Stage(Stage::Removing));+ let mask = predict(&model, image)?;+ Ok(apply_mask(image, &mask))+}++fn model_path() -> PathBuf {+ session::data_root().join("models").join(MODEL_FILE)+}++async fn download_model(path: &Path) -> Result<(), String> {+ let response = reqwest::get(MODEL_URL).await.and_then(|r| r.error_for_status()).map_err(|e| format!("couldn't download the model: {e}"))?;+ let bytes = response.bytes().await.map_err(|e| format!("couldn't download the model: {e}"))?;+ if sha256_hex(&bytes) != MODEL_SHA256 {+ return Err("the downloaded model is not the expected file".to_owned());+ }+ let dir = path.parent().ok_or("no folder for the model")?;+ std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {}: {e}", dir.display()))?;+ // Written under another name first, so a half-written file is never taken for the model.+ let partial = path.with_extension("part");+ std::fs::write(&partial, &bytes).and_then(|()| std::fs::rename(&partial, path)).map_err(|e| format!("couldn't save the model: {e}"))+}++/// How much of each pixel belongs to the subject, `MODEL_SIDE` squared values from 0 to 1.+fn predict(model: &Path, image: &RgbaImage) -> Result<Vec<f32>, String> {+ use tract_onnx::prelude::*;+ let side = MODEL_SIDE as usize;+ let fail = |e: TractError| format!("couldn't remove the background: {e}");+ let runnable = tract_onnx::onnx().model_for_path(model).and_then(|m| m.with_input_fact(0, f32::fact([1, 3, side, side]).into())).and_then(|m| m.into_optimized()).and_then(|m| m.into_runnable()).map_err(fail)?;+ let input = preprocess(image);+ let outputs = runnable.run(tvec!(input.into())).map_err(fail)?;+ let values: Vec<f32> = outputs[0].to_plain_array_view::<f32>().map_err(fail)?.iter().copied().collect();+ if values.len() != side * side {+ return Err("the model gave an unexpected result".to_owned());+ }+ // Stretch to the full 0..1 range so a faint subject still comes out solid.+ let (low, high) = values.iter().fold((f32::MAX, f32::MIN), |(low, high), v| (low.min(*v), high.max(*v)));+ Ok(values.iter().map(|v| (v - low) / (high - low).max(1e-6)).collect())+}++/// The picture squeezed to the model's square, scaled by its brightest value and normalised.+fn preprocess(image: &RgbaImage) -> tract_onnx::prelude::Tensor {+ use tract_onnx::prelude::*;+ let small = image::imageops::resize(image, MODEL_SIDE, MODEL_SIDE, FilterType::Triangle);+ let brightest = small.pixels().flat_map(|p| [p[0], p[1], p[2]]).max().unwrap_or(1).max(1) as f32;+ let side = MODEL_SIDE as usize;+ tract_ndarray::Array4::from_shape_fn((1, 3, side, side), |(_, channel, y, x)| (small.get_pixel(x as u32, y as u32)[channel] as f32 / brightest - MEAN[channel]) / STD[channel]).into()+}++/// The picture with the mask as its transparency; pixels that were already see-through stay so.+fn apply_mask(image: &RgbaImage, mask: &[f32]) -> RgbaImage {+ let small = GrayImage::from_fn(MODEL_SIDE, MODEL_SIDE, |x, y| Luma([(mask[(y * MODEL_SIDE + x) as usize].clamp(0.0, 1.0) * 255.0).round() as u8]));+ let full = image::imageops::resize(&small, image.width(), image.height(), FilterType::Triangle);+ RgbaImage::from_fn(image.width(), image.height(), |x, y| {+ let pixel = image.get_pixel(x, y);+ let alpha = (pixel[3] as u32 * full.get_pixel(x, y)[0] as u32 / 255) as u8;+ image::Rgba([pixel[0], pixel[1], pixel[2], alpha])+ })+}++fn sha256_hex(bytes: &[u8]) -> String {+ Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()+}++#[cfg(test)]+mod tests {+ use super::*;++ const WHITE: image::Rgba<u8> = image::Rgba([255, 255, 255, 255]);++ #[test]+ fn the_mask_becomes_the_transparency() {+ let image = RgbaImage::from_pixel(4, 2, WHITE);+ let kept = apply_mask(&image, &vec![1.0; (MODEL_SIDE * MODEL_SIDE) as usize]);+ let removed = apply_mask(&image, &vec![0.0; (MODEL_SIDE * MODEL_SIDE) as usize]);+ assert!(kept.pixels().all(|p| p[3] == 255));+ assert!(removed.pixels().all(|p| p[3] == 0));+ assert_eq!(kept.dimensions(), (4, 2));+ }++ #[test]+ fn see_through_pixels_stay_see_through() {+ let image = RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 100]));+ let kept = apply_mask(&image, &vec![1.0; (MODEL_SIDE * MODEL_SIDE) as usize]);+ assert!(kept.pixels().all(|p| p.0 == [10, 20, 30, 100]));+ }++ #[test]+ fn the_picture_is_normalised_for_the_model() {+ let input = preprocess(&RgbaImage::from_pixel(8, 8, WHITE));+ let side = MODEL_SIDE as usize;+ assert_eq!(input.shape(), [1, 3, side, side]);+ let view = input.to_plain_array_view::<f32>().unwrap();+ let values = view.as_slice().unwrap();+ assert!((values[5 * side + 5] - (1.0 - MEAN[0]) / STD[0]).abs() < 1e-4);+ assert!((values[2 * side * side + 5 * side + 5] - (1.0 - MEAN[2]) / STD[2]).abs() < 1e-4);+ }++ #[test]+ fn the_hash_is_lowercase_hex() {+ assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");+ }++ /// Downloads the model on first use: `cargo test -p artcraft_native -- --ignored`.+ #[test]+ #[ignore = "needs network access the first time"]+ fn removes_the_background_of_a_generated_picture() {+ let image = RgbaImage::from_fn(200, 120, |x, y| if (x as i32 - 100).pow(2) + (y as i32 - 60).pow(2) < 1600 { image::Rgba([220, 40, 40, 255]) } else { image::Rgba([30, 30, 30, 255]) });+ let rt = tokio::runtime::Runtime::new().unwrap();+ let rx = spawn(rt.handle().clone(), egui::Context::default(), image);+ let result = loop {+ if let Progress::Done(result) = rx.recv_timeout(std::time::Duration::from_secs(120)).unwrap() {+ break result.unwrap();+ }+ };+ assert_eq!(result.dimensions(), (200, 120));+ assert!(result.get_pixel(100, 60)[3] > result.get_pixel(2, 2)[3], "the disc is kept more than the corner");+ }+}@@ -2,6 +2,7 @@ //! [`Event`]s, which the UI drains once per frame; nothing here blocks the UI thread. pub mod audio;+pub mod background; pub mod media_cache; pub mod providers; pub mod session;@@ -2,5 +2,6 @@ pub mod common; pub mod create_audio; pub mod create_image; pub mod create_video;+pub mod remove_background; pub mod other; pub mod toolbar;new file mode 100644@@ -0,0 +1,205 @@+//! BG Remove: pick a picture, take its background off on this machine and save the result as a+//! PNG. The work is done by `backend::background`; nothing is uploaded.++use std::path::PathBuf;+use std::sync::mpsc::Receiver;++use egui::{vec2, Align2, Color32, ColorImage, FontId, Rect, TextureHandle, TextureOptions, Ui};+use image::RgbaImage;++use crate::backend::background::{self, Progress, Stage};+use crate::overlays::lightbox::fit_inside;+use crate::pages::common::Env;+use crate::theme;+use crate::ui::icons::{self, Icon};+use crate::ui::widgets;++const IMAGE_EXTENSIONS: [&str; 4] = ["png", "jpg", "jpeg", "webp"];+const CONTROLS_HEIGHT: f32 = 88.0;+const PANEL_GAP: f32 = 12.0;+/// Larger pictures are shrunk for the screen only; the saved file keeps the full size.+const MAX_TEXTURE_SIDE: u32 = 2048;+const CHECKER_CELL: f32 = 12.0;+const CHECKER_DARK: Color32 = Color32::from_gray(52);+const CHECKER_LIGHT: Color32 = Color32::from_gray(78);++#[derive(Default)]+pub struct RemoveBackgroundPage {+ picture: Option<Picture>,+}++impl RemoveBackgroundPage {+ pub fn ui(&mut self, ui: &mut Ui, env: &mut Env) {+ let area = ui.max_rect();+ if let Some(picture) = &mut self.picture {+ picture.poll(&env.ctx);+ }+ let stage = Rect::from_min_max(area.min, egui::pos2(area.right(), area.bottom() - CONTROLS_HEIGHT)).shrink2(vec2(24.0, 16.0));+ self.stage(ui, stage);++ let mut picked = None;+ let mut save = None;+ let controls = Rect::from_min_max(egui::pos2(area.left() + 24.0, stage.bottom() + 16.0), area.max - vec2(24.0, 16.0));+ let mut child = ui.new_child(egui::UiBuilder::new().max_rect(controls));+ child.horizontal(|ui| {+ if widgets::button(ui, Some(Icon::Folder), if self.picture.is_some() { "Choose another picture" } else { "Choose a picture" }, widgets::Kind::Primary, 0.0, theme::CONTROL_H).clicked() {+ picked = rfd::FileDialog::new().add_filter("Pictures", &IMAGE_EXTENSIONS).pick_file();+ }+ if let Some(Picture { work: Work::Done { result, .. }, path, .. }) = &self.picture {+ if widgets::button(ui, Some(Icon::Download), "Save PNG", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {+ save = Some((saved_file_name(path), result.clone()));+ }+ }+ });+ child.add_space(8.0);+ child.label(egui::RichText::new("Runs on this computer. Nothing is uploaded.").size(12.0).color(theme::MUTED));++ if let Some(path) = picked {+ self.open(path, env);+ }+ if let Some((name, result)) = save {+ if let Some(path) = rfd::FileDialog::new().set_file_name(&name).add_filter("PNG", &["png"]).save_file() {+ match result.save_with_format(&path, image::ImageFormat::Png) {+ Ok(()) => env.toasts.success(format!("Saved {}", path.display())),+ Err(e) => env.toasts.error(format!("Couldn't save the picture: {e}")),+ }+ }+ }+ }++ fn open(&mut self, path: PathBuf, env: &mut Env) {+ let picture = match image::open(&path) {+ Ok(picture) => picture.to_rgba8(),+ Err(e) => {+ env.toasts.error(format!("Couldn't open {}: {e}", path.display()));+ return;+ },+ };+ let original = env.ctx.load_texture("bg-remove-original", for_screen(&picture), TextureOptions::LINEAR);+ let progress = background::spawn(env.backend.runtime(), env.ctx.clone(), picture);+ self.picture = Some(Picture { path, original, work: Work::Running { stage: Stage::Removing, progress } });+ }++ fn stage(&self, ui: &mut Ui, stage: Rect) {+ let half = (stage.width() - PANEL_GAP) / 2.0;+ let left = Rect::from_min_size(stage.min, vec2(half, stage.height()));+ let right = Rect::from_min_size(egui::pos2(left.right() + PANEL_GAP, stage.top()), vec2(half, stage.height()));+ for (rect, label) in [(left, "ORIGINAL"), (right, "NO BACKGROUND")] {+ ui.painter().rect_filled(rect, 0.0, Color32::BLACK);+ ui.painter().text(rect.left_top() + vec2(10.0, 8.0), Align2::LEFT_TOP, label, FontId::new(11.0, egui::FontFamily::Monospace), theme::MUTED);+ }+ let Some(picture) = &self.picture else {+ say(ui, stage, "Choose a picture to take its background off.", theme::MUTED);+ return;+ };+ paint_fitted(ui, left, &picture.original);+ match &picture.work {+ Work::Running { stage: step, .. } => {+ let text = if *step == Stage::DownloadingModel { "Getting the small model (once, about 5 MB)…" } else { "Taking the background off…" };+ icons::paint_spinner(ui, Rect::from_center_size(right.center() - vec2(0.0, 24.0), vec2(32.0, 32.0)), Color32::WHITE);+ ui.painter().text(right.center() + vec2(0.0, 24.0), Align2::CENTER_CENTER, text, FontId::new(13.0, egui::FontFamily::Proportional), theme::MUTED);+ },+ Work::Done { texture, .. } => {+ let fit = fit_inside(texture.size_vec2(), right.shrink(4.0).size());+ let image = Rect::from_center_size(right.center(), fit);+ paint_checkerboard(ui, image);+ egui::Image::new(texture).paint_at(ui, image);+ },+ Work::Failed(message) => say(ui, right, &format!("Couldn't take the background off: {message}"), theme::BAD),+ }+ }+}++struct Picture {+ path: PathBuf,+ original: TextureHandle,+ work: Work,+}++enum Work {+ Running { stage: Stage, progress: Receiver<Progress> },+ Done { result: RgbaImage, texture: TextureHandle },+ Failed(String),+}++impl Picture {+ fn poll(&mut self, ctx: &egui::Context) {+ let Work::Running { stage, progress } = &mut self.work else {+ return;+ };+ let mut finished = None;+ while let Ok(update) = progress.try_recv() {+ match update {+ Progress::Stage(next) => *stage = next,+ Progress::Done(result) => finished = Some(result),+ }+ }+ match finished {+ Some(Ok(result)) => {+ let texture = ctx.load_texture("bg-remove-result", for_screen(&result), TextureOptions::LINEAR);+ self.work = Work::Done { result, texture };+ },+ Some(Err(message)) => self.work = Work::Failed(message),+ None => ctx.request_repaint_after(std::time::Duration::from_millis(30)),+ }+ }+}++fn say(ui: &mut Ui, rect: Rect, text: &str, color: Color32) {+ ui.painter().text(rect.center(), Align2::CENTER_CENTER, text, FontId::new(14.0, egui::FontFamily::Proportional), color);+}++fn paint_fitted(ui: &mut Ui, panel: Rect, texture: &TextureHandle) {+ let fit = fit_inside(texture.size_vec2(), panel.shrink(4.0).size());+ egui::Image::new(texture).paint_at(ui, Rect::from_center_size(panel.center(), fit));+}++/// The grey squares that show through the see-through parts.+fn paint_checkerboard(ui: &mut Ui, rect: Rect) {+ let painter = ui.painter();+ painter.rect_filled(rect, 0.0, CHECKER_DARK);+ let (columns, rows) = ((rect.width() / CHECKER_CELL).ceil() as i32, (rect.height() / CHECKER_CELL).ceil() as i32);+ for row in 0..rows {+ for column in (row % 2..columns).step_by(2) {+ let cell = Rect::from_min_size(rect.min + vec2(column as f32 * CHECKER_CELL, row as f32 * CHECKER_CELL), vec2(CHECKER_CELL, CHECKER_CELL));+ painter.rect_filled(cell.intersect(rect), 0.0, CHECKER_LIGHT);+ }+ }+}++/// The picture as a texture, shrunk if it is bigger than a graphics card is guaranteed to take.+fn for_screen(image: &RgbaImage) -> ColorImage {+ let (width, height) = image.dimensions();+ let largest = width.max(height);+ let shown = if largest > MAX_TEXTURE_SIDE {+ let scale = MAX_TEXTURE_SIDE as f64 / largest as f64;+ image::imageops::resize(image, ((width as f64 * scale).round() as u32).max(1), ((height as f64 * scale).round() as u32).max(1), image::imageops::FilterType::Triangle)+ } else {+ image.clone()+ };+ ColorImage::from_rgba_unmultiplied([shown.width() as usize, shown.height() as usize], shown.as_raw())+}++/// `holiday.jpg` is offered as `holiday_no-background.png`.+fn saved_file_name(source: &std::path::Path) -> String {+ let stem = source.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "picture".to_owned());+ format!("{stem}_no-background.png")+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn saved_names_keep_the_picture_name() {+ assert_eq!(saved_file_name(std::path::Path::new("/photos/holiday.jpg")), "holiday_no-background.png");+ assert_eq!(saved_file_name(std::path::Path::new("")), "picture_no-background.png");+ }++ #[test]+ fn big_pictures_are_shrunk_for_the_screen_only() {+ let big = RgbaImage::new(4096, 1024);+ assert_eq!(for_screen(&big).size, [2048, 512]);+ assert_eq!(for_screen(&RgbaImage::new(300, 200)).size, [300, 200]);+ }+}@@ -88,6 +88,7 @@ impl Page { "create-image" | "image" => Page::CreateImage, "create-video" | "video" => Page::CreateVideo, "create-audio" | "audio" => Page::CreateAudio,+ "remove-background" | "bg-remove" => Page::RemoveBackground, "library" => Page::Library, _ => return None, })@@ -95,7 +96,7 @@ impl Page { /// Pages the native app has; the rest open a "coming soon" page. pub fn is_ready(self) -> bool {- matches!(self, Page::Home | Page::CreateImage | Page::CreateVideo | Page::CreateAudio | Page::Library)+ matches!(self, Page::Home | Page::CreateImage | Page::CreateVideo | Page::CreateAudio | Page::RemoveBackground | Page::Library) } /// The same tool in the web app.