diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/artcraft-native/Cargo.toml b/artcraft-native/Cargo.toml
--- a/artcraft-native/Cargo.toml
+++ b/artcraft-native/Cargo.toml
@@ -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
diff --git a/artcraft-native/src/app.rs b/artcraft-native/src/app.rs
--- a/artcraft-native/src/app.rs
+++ b/artcraft-native/src/app.rs
@@ -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),
           }
diff --git a/artcraft-native/src/backend/background.rs b/artcraft-native/src/backend/background.rs
new file mode 100644
--- /dev/null
+++ b/artcraft-native/src/backend/background.rs
@@ -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");
+  }
+}
diff --git a/artcraft-native/src/backend/mod.rs b/artcraft-native/src/backend/mod.rs
--- a/artcraft-native/src/backend/mod.rs
+++ b/artcraft-native/src/backend/mod.rs
@@ -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;
diff --git a/artcraft-native/src/pages/mod.rs b/artcraft-native/src/pages/mod.rs
--- a/artcraft-native/src/pages/mod.rs
+++ b/artcraft-native/src/pages/mod.rs
@@ -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;
diff --git a/artcraft-native/src/pages/remove_background.rs b/artcraft-native/src/pages/remove_background.rs
new file mode 100644
--- /dev/null
+++ b/artcraft-native/src/pages/remove_background.rs
@@ -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]);
+  }
+}
diff --git a/artcraft-native/src/shell.rs b/artcraft-native/src/shell.rs
--- a/artcraft-native/src/shell.rs
+++ b/artcraft-native/src/shell.rs
@@ -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.
