From fd4019300aec2ce4b91e44b2ed5b4852c4fd8265 Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:47:57 -0400 Subject: [PATCH] LR-EDIT-DETAIL-DENOISE: address installation and model-policy review Generated with Codex. --- Cargo.lock | 17 ++ Cargo.toml | 3 +- NOTICE | 9 + apps/lightcraft-cli/Cargo.toml | 1 + apps/lightcraft-web/src/web.rs | 1 + apps/lightcraft/Cargo.toml | 1 + apps/lightcraft/src/main.rs | 7 + crates/denoise-core/Cargo.toml | 26 +++ crates/denoise-core/README.md | 7 + .../{denoise => denoise-core}/src/archive.rs | 16 +- crates/{denoise => denoise-core}/src/bayer.rs | 0 crates/{denoise => denoise-core}/src/hash.rs | 0 crates/{denoise => denoise-core}/src/known.rs | 28 ++- crates/denoise-core/src/lib.rs | 16 ++ .../{denoise => denoise-core}/src/licence.rs | 0 .../{denoise => denoise-core}/src/manifest.rs | 0 .../{denoise => denoise-core}/src/product.rs | 39 +++- crates/{denoise => denoise-core}/src/run.rs | 25 ++- crates/{denoise => denoise-core}/src/tiles.rs | 0 crates/denoise/Cargo.toml | 10 +- crates/denoise/README.md | 8 +- crates/denoise/src/cpu.rs | 2 +- crates/denoise/src/lib.rs | 17 +- crates/denoise/src/runtime.rs | 18 +- crates/denoise/tests/real_model.rs | 4 +- crates/engine/Cargo.toml | 6 +- crates/engine/src/cmd/denoise.rs | 186 ++++++++++++++---- crates/engine/src/denoise.rs | 29 ++- crates/engine/src/files.rs | 33 ++-- crates/engine/src/lib.rs | 4 + crates/engine/src/media.rs | 21 +- crates/engine/src/model_download.rs | 12 +- crates/engine/src/tests_denoise.rs | 62 +++++- crates/gpu/Cargo.toml | 6 +- crates/gpu/src/lib.rs | 4 +- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/locales/de-formats.json | 21 +- crates/ui-egui/locales/de.json | 91 ++++++++- crates/ui-egui/locales/es-formats.json | 21 +- crates/ui-egui/locales/es.json | 91 ++++++++- crates/ui-egui/locales/ja-formats.json | 21 +- crates/ui-egui/locales/ja.json | 91 ++++++++- crates/ui-egui/locales/pt-br-formats.json | 21 +- crates/ui-egui/locales/pt-br.json | 91 ++++++++- crates/ui-egui/locales/ru-formats.json | 21 +- crates/ui-egui/locales/ru.json | 92 ++++++++- crates/ui-egui/locales/zh-hans-formats.json | 21 +- crates/ui-egui/locales/zh-hans.json | 91 ++++++++- crates/ui-egui/locales/zh-hant-formats.json | 21 +- crates/ui-egui/locales/zh-hant.json | 91 ++++++++- crates/ui-egui/src/headless.rs | 5 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/model_setup.rs | 13 +- crates/ui-egui/src/panels/denoise.rs | 155 +++++++++------ crates/ui-egui/src/panels/edit.rs | 2 +- crates/ui-egui/src/tests_model_setup.rs | 35 +++- docs/denoise.md | 31 ++- docs/parity.md | 4 +- xtask/src/layers.rs | 2 + 59 files changed, 1437 insertions(+), 217 deletions(-) create mode 100644 crates/denoise-core/Cargo.toml create mode 100644 crates/denoise-core/README.md rename crates/{denoise => denoise-core}/src/archive.rs (94%) rename crates/{denoise => denoise-core}/src/bayer.rs (100%) rename crates/{denoise => denoise-core}/src/hash.rs (100%) rename crates/{denoise => denoise-core}/src/known.rs (81%) create mode 100644 crates/denoise-core/src/lib.rs rename crates/{denoise => denoise-core}/src/licence.rs (100%) rename crates/{denoise => denoise-core}/src/manifest.rs (100%) rename crates/{denoise => denoise-core}/src/product.rs (94%) rename crates/{denoise => denoise-core}/src/run.rs (97%) rename crates/{denoise => denoise-core}/src/tiles.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 6de52a46..d082de8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3052,6 +3052,22 @@ name = "lightcraft-denoise" version = "0.4.0" dependencies = [ "faer-core", + "lightcraft-denoise-core", + "lightcraft-raster", + "miniz_oxide 0.8.9", + "proptest", + "rayon", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.21", + "web-time", +] + +[[package]] +name = "lightcraft-denoise-core" +version = "0.4.0" +dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", "proptest", @@ -3086,6 +3102,7 @@ dependencies = [ "lightcraft-codecs", "lightcraft-color", "lightcraft-denoise", + "lightcraft-denoise-core", "lightcraft-develop", "lightcraft-fetch", "lightcraft-geom", diff --git a/Cargo.toml b/Cargo.toml index b23fae21..daff5840 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,8 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] # internal -lightcraft-denoise = { path = "crates/denoise" } +lightcraft-denoise-core = { path = "crates/denoise-core" } +lightcraft-denoise = { path = "crates/denoise", default-features = false } lightcraft-geom = { path = "crates/geom" } lightcraft-sysmem = { path = "crates/sysmem" } lightcraft-color = { path = "crates/color" } diff --git a/NOTICE b/NOTICE index f3f6499f..254e2628 100644 --- a/NOTICE +++ b/NOTICE @@ -27,6 +27,15 @@ the facebook/sam3 checkpoint only when they choose to (in the app, or by hand), License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI licence. See docs/ai-masks.md. +AI denoise weights are not part of LightCraft and are never distributed with it. +The default build offers user-supplied models only. The optional rawnind-model +feature offers a separately downloaded RawNIND UtNet2 Bayer checkpoint under +GPL-3.0 (https://www.gnu.org/licenses/gpl-3.0.html), after the user accepts its terms. +Commercial use is subject to those terms, including applicable source, licence and +notice obligations on redistribution. Enabling that offer in releases remains a +maintainer decision. The ONNX reader and CPU/GPU executor are our Rust implementation; +no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included. + Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases) also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml index 72d46858..bafe3287 100644 --- a/apps/lightcraft-cli/Cargo.toml +++ b/apps/lightcraft-cli/Cargo.toml @@ -27,6 +27,7 @@ dhat = { workspace = true, optional = true } [features] default = ["denoise"] denoise = ["lightcraft-engine/denoise"] +rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"] # Heap profiling: `cargo run --release -p lightcraft-cli --features dhat-heap -- snapshot …` counts # every allocation (live/peak bytes in `library.memory` / `ui.inspect`) and writes dhat-heap.json # (open it in DHAT's viewer: allocation sites by bytes at the peak). Slows allocation-heavy work. diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 9ffbf170..0b5d4c26 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -205,6 +205,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen open_picker(originals.clone(), backend.clone(), ctx.clone()); Vec::new() // files arrive asynchronously and are imported on a later frame })), + pick_denoise_model: None, // Preset files: browser pickers are asynchronous; not wired on the web yet. pick_preset_files: None, pick_tracklog: None, diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index 3f3f40ba..f5fe2763 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [features] default = ["denoise"] denoise = ["lightcraft-engine/denoise"] +rawnind-model = ["denoise", "lightcraft-engine/rawnind-model"] [dependencies] lightcraft-engine = { workspace = true, features = ["sam"] } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 44f0548d..e6629c00 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -387,6 +387,13 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .map(|p| p.to_string_lossy().to_string()) .collect() })), + pick_denoise_model: Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ONNX model"), &["onnx"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().into_owned()]) + .unwrap_or_default() + })), pick_preset_files: Some(Box::new(|| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Import Presets")) diff --git a/crates/denoise-core/Cargo.toml b/crates/denoise-core/Cargo.toml new file mode 100644 index 00000000..b65f90ae --- /dev/null +++ b/crates/denoise-core/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lightcraft-denoise-core" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft denoise model metadata, Bayer tiling and cache formats (L1; no inference backend)" + +[lints] +workspace = true + +[features] +default = [] +rawnind-model = [] + +[dependencies] +lightcraft-raster = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +miniz_oxide = { workspace = true } +rayon = { workspace = true } +web-time = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/denoise-core/README.md b/crates/denoise-core/README.md new file mode 100644 index 00000000..74d1e324 --- /dev/null +++ b/crates/denoise-core/README.md @@ -0,0 +1,7 @@ +# lightcraft-denoise-core + +L1 model metadata, bounded archive/cache formats and Bayer packing/tiling helpers. +No weights or inference backend. See [the user workflow](../../docs/denoise.md). +`lightcraft-denoise` re-exports these types for CPU/GPU users; the engine uses this +crate directly when inference is disabled. The default download catalog is empty. +Run `cargo test -p lightcraft-denoise-core`; optional catalog checks use `--features rawnind-model`. diff --git a/crates/denoise/src/archive.rs b/crates/denoise-core/src/archive.rs similarity index 94% rename from crates/denoise/src/archive.rs rename to crates/denoise-core/src/archive.rs index ddaf7084..ccfea471 100644 --- a/crates/denoise/src/archive.rs +++ b/crates/denoise-core/src/archive.rs @@ -127,13 +127,21 @@ pub fn extract(archive: &Path, name: &str, dest: &Path, max_bytes: u64) -> Resul } let skip = u64::from(u16_at(&head, 26).unwrap_or(0)) + u64::from(u16_at(&head, 28).unwrap_or(0)); f.seek_relative(i64::try_from(skip).map_err(|_| ArchiveError::Format("a header is too long".into()))?)?; - let mut packed = vec![0u8; e.compressed as usize]; + let len = usize::try_from(e.compressed) + .ok() + .filter(|&n| n <= 256 * 1024 * 1024) + .ok_or_else(|| ArchiveError::Format("the compressed model is too large".into()))?; + if e.size > 256 * 1024 * 1024 { + return format("the extracted model is too large"); + } + let mut packed = Vec::new(); + packed.try_reserve_exact(len).map_err(|_| ArchiveError::Format("not enough memory for the model archive".into()))?; + packed.resize(len, 0u8); f.read_exact(&mut packed)?; let data = match e.method { 0 => packed, - 8 => { - miniz_oxide::inflate::decompress_to_vec_with_limit(&packed, max_bytes as usize).map_err(|err| ArchiveError::Format(format!("{err:?}")))? - } + 8 => miniz_oxide::inflate::decompress_to_vec_with_limit(&packed, max_bytes.min(256 * 1024 * 1024) as usize) + .map_err(|err| ArchiveError::Format(format!("{err:?}")))?, m => return format(&format!("compression method {m} is not supported")), }; if data.len() as u64 != e.size || crc32(&data) != e.crc { diff --git a/crates/denoise/src/bayer.rs b/crates/denoise-core/src/bayer.rs similarity index 100% rename from crates/denoise/src/bayer.rs rename to crates/denoise-core/src/bayer.rs diff --git a/crates/denoise/src/hash.rs b/crates/denoise-core/src/hash.rs similarity index 100% rename from crates/denoise/src/hash.rs rename to crates/denoise-core/src/hash.rs diff --git a/crates/denoise/src/known.rs b/crates/denoise-core/src/known.rs similarity index 81% rename from crates/denoise/src/known.rs rename to crates/denoise-core/src/known.rs index 9e0e7ea1..f9a51109 100644 --- a/crates/denoise/src/known.rs +++ b/crates/denoise-core/src/known.rs @@ -3,9 +3,12 @@ //! None is part of LightCraft. A model with an address here can be downloaded when the user asks and has accepted //! its terms; anything else is brought by the user as an `.onnx` file with a `denoise-model.json` beside it. +#[cfg(any(feature = "rawnind-model", test))] use crate::licence::{Commercial, Licence}; -use crate::manifest::{DenoiserManifest, Domain, Gain}; +use crate::manifest::DenoiserManifest; +#[cfg(any(feature = "rawnind-model", test))] +use crate::manifest::{Domain, Gain}; /// A known model, and how to get it. pub struct Known { @@ -18,6 +21,7 @@ pub struct Known { /// RawNIND's UtNet2 for Bayer sensors, as published for darktable (`rawdenoise-nind.dtmodel`, a zip with a Bayer and a /// linear model). Its weights are GPL-3.0; the training pictures are CC BY 4.0 and CC0. +#[cfg(any(feature = "rawnind-model", test))] fn rawnind_bayer() -> Known { let manifest = DenoiserManifest { id: "rawnind-bayer".into(), @@ -29,7 +33,7 @@ fn rawnind_bayer() -> Known { url: Some("https://www.gnu.org/licenses/gpl-3.0.html".into()), notice: "The weights are published under the GNU General Public License v3. LightCraft does not include them: they \ are downloaded to your computer at your request and used by it only. If you pass the model file on, the \ - GPL applies to that copy." + GPL applies to that copy. Commercial use is subject to the GPL terms, including applicable source, licence and notice obligations on redistribution." .into(), }, source: Some("https://github.com/darktable-org/darktable-ai/tree/master/models/rawdenoise-nind".into()), @@ -55,7 +59,14 @@ fn rawnind_bayer() -> Known { /// Every model LightCraft knows. pub fn all() -> Vec { - vec![rawnind_bayer()] + #[cfg(feature = "rawnind-model")] + { + vec![rawnind_bayer()] + } + #[cfg(not(feature = "rawnind-model"))] + { + Vec::new() + } } /// The known model with this id. @@ -76,7 +87,8 @@ mod tests { #[test] fn known_models_are_valid_unique_and_downloaded_over_https() { let all = all(); - assert!(!all.is_empty()); + assert_eq!(all.is_empty(), !cfg!(feature = "rawnind-model")); + validate(&rawnind_bayer().manifest).unwrap(); for (i, k) in all.iter().enumerate() { validate(&k.manifest).unwrap(); assert!(all.iter().skip(i + 1).all(|o| o.manifest.id != k.manifest.id), "unique ids"); @@ -92,8 +104,12 @@ mod tests { assert!(k.download.is_none(), "{}", k.manifest.id); } } - assert!(find("rawnind-bayer").is_some() && find("nothing").is_none()); - assert_eq!(by_sha256("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901").map(|m| m.id), Some("rawnind-bayer".into())); + assert_eq!(find("rawnind-bayer").is_some(), cfg!(feature = "rawnind-model")); + assert!(find("nothing").is_none()); + assert_eq!( + by_sha256("da27509dab6a2915da67e988acd86cf71f9d5bbc8d1aa0ed32933578a887b901").map(|m| m.id), + if cfg!(feature = "rawnind-model") { Some("rawnind-bayer".into()) } else { None } + ); } } diff --git a/crates/denoise-core/src/lib.rs b/crates/denoise-core/src/lib.rs new file mode 100644 index 00000000..568ed9f8 --- /dev/null +++ b/crates/denoise-core/src/lib.rs @@ -0,0 +1,16 @@ +//! Model metadata, bounded archive/cache formats and Bayer tiling, independent of inference. +//! Used by featureless engine and web builds; no neural-network or matrix backend is linked. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod archive; +pub mod bayer; +pub mod hash; +pub mod known; +pub mod licence; +pub mod manifest; +pub mod product; +pub mod run; +pub mod tiles; + +pub use manifest::{DenoiserManifest, Domain, Gain, ManifestError}; +pub use run::{Control, Error, Params, TileRunner, denoise_bayer}; diff --git a/crates/denoise/src/licence.rs b/crates/denoise-core/src/licence.rs similarity index 100% rename from crates/denoise/src/licence.rs rename to crates/denoise-core/src/licence.rs diff --git a/crates/denoise/src/manifest.rs b/crates/denoise-core/src/manifest.rs similarity index 100% rename from crates/denoise/src/manifest.rs rename to crates/denoise-core/src/manifest.rs diff --git a/crates/denoise/src/product.rs b/crates/denoise-core/src/product.rs similarity index 94% rename from crates/denoise/src/product.rs rename to crates/denoise-core/src/product.rs index 1e393885..5a607c8f 100644 --- a/crates/denoise/src/product.rs +++ b/crates/denoise-core/src/product.rs @@ -52,6 +52,13 @@ pub struct Header { pub key: String, } +fn allocate(n: usize, value: T) -> Result, ProductError> { + let mut v = Vec::new(); + v.try_reserve_exact(n).map_err(|_| ProductError::Format("not enough memory for the denoised picture".into()))?; + v.resize(n, value); + Ok(v) +} + /// `f32` as IEEE half-float bits, rounded to nearest even; beyond the range it saturates, NaN becomes 0. pub fn to_f16(v: f32) -> u16 { if !v.is_finite() { @@ -120,9 +127,9 @@ fn crc(bytes: &[u8]) -> u32 { } /// One strip as bytes: for each plane (R, G, B) the low bytes of its half floats, then the high bytes. -fn encode_strip(img: &Rgb32f, y0: usize, y1: usize) -> Vec { +fn encode_strip(img: &Rgb32f, y0: usize, y1: usize) -> Result, ProductError> { let n = (y1 - y0) * img.width; - let mut raw = vec![0u8; 6 * n]; + let mut raw = allocate(6 * n, 0u8)?; let rows = img.data.get(y0 * img.width..y1 * img.width).unwrap_or(&[]); for (i, px) in rows.iter().enumerate() { for (c, &v) in px.iter().enumerate() { @@ -135,16 +142,17 @@ fn encode_strip(img: &Rgb32f, y0: usize, y1: usize) -> Vec { } } } - miniz_oxide::deflate::compress_to_vec(&raw, 3) + Ok(miniz_oxide::deflate::compress_to_vec(&raw, 3)) } /// Write `img` to `path` (through a temporary file next to it, so a reader never sees half a product). pub fn write(path: &Path, img: &Rgb32f, key: &str) -> Result<(), ProductError> { - if img.width == 0 || img.height == 0 || img.width > MAX_SIDE || img.height > MAX_SIDE || img.data.len() != img.width * img.height { + let pixels = img.width.checked_mul(img.height).filter(|&n| n <= MAX_PIXELS); + if img.width == 0 || img.height == 0 || img.width > MAX_SIDE || img.height > MAX_SIDE || pixels != Some(img.data.len()) { return Err(ProductError::Format("the picture has no usable size".into())); } let strips: Vec<(usize, usize)> = (0..img.height).step_by(STRIP_ROWS).map(|y| (y, (y + STRIP_ROWS).min(img.height))).collect(); - let packed: Vec> = strips.par_iter().map(|&(a, b)| encode_strip(img, a, b)).collect(); + let packed: Vec> = strips.par_iter().map(|&(a, b)| encode_strip(img, a, b)).collect::>()?; let header = serde_json::to_vec(&Header { width: img.width, height: img.height, strip: STRIP_ROWS, key: key.to_string() }) .map_err(|e| ProductError::Format(e.to_string()))?; let tmp = path.with_extension("tmp"); @@ -193,7 +201,7 @@ fn read_start(r: &mut impl Read) -> Result { if len > MAX_HEADER { return Err(ProductError::Format("the header is too large".into())); } - let mut hb = vec![0u8; len]; + let mut hb = allocate(len, 0u8)?; r.read_exact(&mut hb)?; let h: Header = serde_json::from_slice(&hb).map_err(|e| ProductError::Format(e.to_string()))?; let pixels = h.width.saturating_mul(h.height); @@ -297,7 +305,7 @@ pub fn read_window(path: &Path, key: &str, window: Option, factor: usize } // the picture rows and columns the blocks cover let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) }; - let mut out = Rgb32f { width: ow, height: oh, data: vec![[0.0; 3]; ow * oh] }; + let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? }; let lut = f16_table(); let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 { let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0)); @@ -305,7 +313,7 @@ pub fn read_window(path: &Path, key: &str, window: Option, factor: usize }; // which strips are needed, and where each starts in the file (after the header and the strip table) let wanted = |s: usize| s * h.strip < y_end && (s + 1) * h.strip > w.y; - let (mut sum, mut peak) = (vec![[0f32; 3]; ow], vec![[f32::MIN; 3]; ow]); + let (mut sum, mut peak) = (allocate(ow, [0f32; 3])?, allocate(ow, [f32::MIN; 3])?); let (mut rows_in, mut oy) = (0usize, 0usize); let strips: Vec = (0..table.len()).filter(|&s| wanted(s)).collect(); // skip the strips before the first one wanted @@ -315,7 +323,7 @@ pub fn read_window(path: &Path, key: &str, window: Option, factor: usize let mut packed = Vec::with_capacity(group.len()); for &s in group { let Some(&(len, _)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) }; - let mut p = vec![0u8; len]; + let mut p = allocate(len, 0u8)?; f.read_exact(&mut p)?; packed.push(p); } @@ -401,6 +409,19 @@ pub fn blend(base: &mut Rgb32f, denoised: &Rgb32f, amount: f32) -> bool { mod tests { use super::*; + #[test] + fn oversized_product_header_is_rejected_before_allocating_pixels() { + let p = temp("oversized-header"); + let header = serde_json::to_vec(&Header { width: 10001, height: 10000, strip: STRIP_ROWS, key: "k".into() }).unwrap(); + let mut bytes = Vec::from(MAGIC.as_slice()); + bytes.extend_from_slice(&[VERSION, 0, 0, 0]); + bytes.extend_from_slice(&(header.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&header); + std::fs::write(&p, bytes).unwrap(); + assert!(read_header_at(&p).is_err()); + let _ = std::fs::remove_file(p); + } + fn temp(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id())) } diff --git a/crates/denoise/src/run.rs b/crates/denoise-core/src/run.rs similarity index 97% rename from crates/denoise/src/run.rs rename to crates/denoise-core/src/run.rs index 5bc763ce..1df96a9e 100644 --- a/crates/denoise/src/run.rs +++ b/crates/denoise-core/src/run.rs @@ -14,7 +14,7 @@ use crate::manifest::Gain; use crate::tiles::{starts, weight}; /// Most pixels one picture may have (guards the allocations that follow from its size). -pub const MAX_PIXELS: usize = 400_000_000; +pub const MAX_PIXELS: usize = 100_000_000; /// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from. const MIN_SIGNAL: f32 = 0.003; @@ -32,6 +32,13 @@ pub enum Error { Cancelled, } +fn allocate(n: usize, value: T) -> Result, Error> { + let mut v = Vec::new(); + v.try_reserve_exact(n).map_err(|_| Error::Input("not enough memory for denoise".into()))?; + v.resize(n, value); + Ok(v) +} + /// Something that runs the model on one tile. pub trait TileRunner: Sync { /// `input` is the tile as four planes `[R, G1, G2, B]` of `tile × tile` cells (NCHW, batch 1); the answer is @@ -112,13 +119,13 @@ pub fn denoise_bayer( .collect(); let total = tiles.len(); - let mut acc = vec![[0f32; 3]; pixels]; - let mut wsum = vec![0f32; nx * ny]; - let group = p.parallel.clamp(1, 64); + let mut acc = allocate(pixels, [0f32; 3])?; + let mut wsum = allocate(nx * ny, 0f32)?; + let group = p.parallel.clamp(1, 4); let mut done = 0; // where the time goes, printed under `LIGHTCRAFT_PROFILE` let (mut t_wait, mut t_total) = (std::time::Duration::ZERO, std::time::Duration::ZERO); - let began = std::time::Instant::now(); + let began = web_time::Instant::now(); // One group of tiles at a time: pack, run the model, check and bring each to the input's scale, in parallel. // Blending (sequential: the tiles add into one picture, in tile order so the result does not depend on timing) of @@ -128,7 +135,7 @@ pub fn denoise_bayer( chunk .par_iter() .map(|&(x0, y0, bx, ax, by, ay)| { - let mut input = vec![0f32; 4 * p.tile * p.tile]; + let mut input = allocate(4 * p.tile * p.tile, 0f32)?; if !pack_tile(mosaic, width, height, layout, x0 as isize, y0 as isize, p.tile, &mut input) { return Err(Error::Input("a tile could not be packed".into())); } @@ -164,7 +171,7 @@ pub fn denoise_bayer( || compute(chunk), || match previous.take() { Some((c, o)) => { - let started = std::time::Instant::now(); + let started = web_time::Instant::now(); let r = blend(c, o); (r, started.elapsed()) } @@ -176,12 +183,12 @@ pub fn denoise_bayer( previous = Some((chunk, outs)); } if let Some((c, o)) = previous.take() { - let started = std::time::Instant::now(); + let started = web_time::Instant::now(); blend(c, o)?; t_wait += started.elapsed(); } t_total += began.elapsed(); - let started = std::time::Instant::now(); + let started = web_time::Instant::now(); // normalise by the weights let (ox, oy) = (layout.position(0, 0).0, layout.position(0, 0).1); acc.par_chunks_mut(width).enumerate().for_each(|(y, row)| { diff --git a/crates/denoise/src/tiles.rs b/crates/denoise-core/src/tiles.rs similarity index 100% rename from crates/denoise/src/tiles.rs rename to crates/denoise-core/src/tiles.rs diff --git a/crates/denoise/Cargo.toml b/crates/denoise/Cargo.toml index 55c44a14..621fd3d6 100644 --- a/crates/denoise/Cargo.toml +++ b/crates/denoise/Cargo.toml @@ -10,11 +10,15 @@ workspace = true [features] default = [] +runtime = ["dep:faer"] +# Product-policy switch: absent by default; users may install their own model. +rawnind-model = ["lightcraft-denoise-core/rawnind-model"] [dependencies] -# Safe views over Rust-intrinsic gemm kernels. Pin the core-only release: newer backends -# generate assembly. No BLAS, cc/build scripts, nightly/AMX, or inner thread pool. -faer = { package = "faer-core", version = "=0.17.1", default-features = false, features = ["std"] } +lightcraft-denoise-core = { workspace = true } +# Safe matrix views over the pure-Rust GEMM backend; no BLAS, cc/build scripts +# or inner thread pool. Its dependencies include architecture-specific assembly. +faer = { package = "faer-core", version = "=0.17.1", default-features = false, features = ["std"], optional = true } lightcraft-raster = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/denoise/README.md b/crates/denoise/README.md index cb69e382..0397193f 100644 --- a/crates/denoise/README.md +++ b/crates/denoise/README.md @@ -3,12 +3,16 @@ L1 pure-Rust AI RAW denoise primitives and CPU inference. No weights are bundled. See [user workflow and limitations](../../docs/denoise.md). -`manifest` and `known` validate opt-in models; `onnx` reads a bounded operator subset +`lightcraft-denoise-core` provides metadata, Bayer helpers and cache formats, +re-exported here. `manifest` and `known` validate opt-in models; `onnx` reads a bounded operator subset into `net`; `cpu` executes it and `reference` supplies scalar checks. `bayer`, `tiles` and `run` pack sensor data and blend overlapping tiles while preserving clipped highlights. `product` reads/writes the disposable `.lcdn` cache. `runtime` supplies the installation self-test. Synthetic networks are generated in Rust for tests. -Run `cargo test -p lightcraft-denoise`. Real-model tests are ignored by default and +Run `cargo test -p lightcraft-denoise --features runtime`. The CPU backend is optional; +featureless and web builds keep the model metadata and cache format without faer/GEMM. +The `rawnind-model` feature enables the otherwise empty download catalog and requires +an explicit release-policy decision. Real-model tests are ignored by default and require an explicitly supplied local model. The GPU twin is `lightcraft-gpu::nn`; model management, background jobs and export integration live in `lightcraft-engine`. diff --git a/crates/denoise/src/cpu.rs b/crates/denoise/src/cpu.rs index a856fc25..34e5269e 100644 --- a/crates/denoise/src/cpu.rs +++ b/crates/denoise/src/cpu.rs @@ -13,7 +13,7 @@ const BLOCK: usize = 256; /// Cap all tensor buffers and convolution scratch for one tile together (512 MiB). const MAX_VALUES: usize = 128 * 1024 * 1024; const MAX_TILE: usize = 2048; -const MAX_IDLE: usize = 16; +const MAX_IDLE: usize = 1; type Result = std::result::Result; fn bad(why: &str) -> NetError { diff --git a/crates/denoise/src/lib.rs b/crates/denoise/src/lib.rs index c7c76b58..e72860ba 100644 --- a/crates/denoise/src/lib.rs +++ b/crates/denoise/src/lib.rs @@ -18,25 +18,20 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -pub mod archive; -pub mod bayer; +#[cfg(feature = "runtime")] pub mod cpu; -pub mod known; -pub mod manifest; + pub mod net; pub mod onnx; mod onnx_proto; -pub mod product; + pub mod reference; -pub mod run; + +#[cfg(feature = "runtime")] pub mod runtime; #[doc(hidden)] pub mod synthetic; -pub mod tiles; -pub use manifest::{DenoiserManifest, Domain, Gain, ManifestError}; -pub use run::{Control, Error, Params, TileRunner, denoise_bayer}; +pub use lightcraft_denoise_core::*; -pub mod hash; -pub mod licence; pub mod synthetic_proto; diff --git a/crates/denoise/src/runtime.rs b/crates/denoise/src/runtime.rs index 4314537a..117f6775 100644 --- a/crates/denoise/src/runtime.rs +++ b/crates/denoise/src/runtime.rs @@ -408,10 +408,20 @@ mod cpu_tests { } let path = std::env::temp_dir().join(format!("lc-cpu-load-{}.onnx", std::process::id())); std::fs::write(&path, crate::synthetic::unet_onnx(64, 5, 1, 7)).unwrap(); - let mut m = crate::known::find("rawnind-bayer").unwrap().manifest; - m.tile = 64; - m.overlap = 16; - m.gain = Gain::None; + let mut m = DenoiserManifest { + id: "synthetic".into(), + name: "Synthetic".into(), + version: "1".into(), + licence: Default::default(), + source: None, + sha256: None, + size_bytes: None, + provenance: "synthetic test".into(), + domain: crate::manifest::Domain::BayerToRgb, + tile: 64, + overlap: 16, + gain: Gain::None, + }; let r = CpuRunner::load(&path, &m).unwrap(); let x = check_tile(64); assert_eq!(r.run(&x).unwrap(), r.run(&x).unwrap()); diff --git a/crates/denoise/tests/real_model.rs b/crates/denoise/tests/real_model.rs index bea7e712..203e9937 100644 --- a/crates/denoise/tests/real_model.rs +++ b/crates/denoise/tests/real_model.rs @@ -2,7 +2,7 @@ //! //! ```text //! LC_DENOISE_MODEL= LC_DENOISE_MOSAICS= LC_DENOISE_OUT= \ -//! cargo test --release -p lightcraft-denoise --test real_model -- --ignored --nocapture +//! cargo test --release -p lightcraft-denoise --features runtime --test real_model -- --ignored --nocapture //! ``` //! //! `LC_DENOISE_MOSAICS` holds `.mosaic.f32` (little-endian f32, normalised: black 0, white 1, not white @@ -11,6 +11,8 @@ //! once, default 8), `LC_DENOISE_ONLY` (one name), `LC_DENOISE_ISO` (noise strength, default 1.0; 0 adds none), //! `LC_DENOISE_FULL` (the whole frame instead of a 2048 × 1536 crop). +#![cfg(feature = "runtime")] + use std::path::{Path, PathBuf}; use std::time::Instant; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 536df716..f11ca467 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -21,7 +21,8 @@ web-time = { workspace = true } lightcraft-preview = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raw = { workspace = true } -lightcraft-denoise = { workspace = true } +lightcraft-denoise-core = { workspace = true } +lightcraft-denoise = { workspace = true, optional = true } lightcraft-meta = { workspace = true } miniz_oxide = { workspace = true } lightcraft-merge = { workspace = true } @@ -39,7 +40,8 @@ lightcraft-segment = { workspace = true, optional = true } [features] # AI masks with SAM 3 (Object / Describe). Off for the web and CLI builds. sam = ["dep:lightcraft-segment"] -denoise = [] +denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-gpu/denoise"] +rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"] [target.'cfg(not(target_arch = "wasm32"))'.dependencies] lightcraft-fetch = { workspace = true } diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index c5103791..e1cc0900 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -7,7 +7,7 @@ //! validated before they become folder names, and nothing outside a model's own folder is ever touched. use std::path::{Path, PathBuf}; -use std::sync::PoisonError; +use std::sync::{Arc, PoisonError, mpsc}; use lightcraft_catalog::PhotoId; use lightcraft_denoise::archive; @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; use crate::denoise::{Pace, RunOn, Settings, forget_gpu_set_ups, installed_models, read_capped, read_settings, write_atomic, write_settings}; -use crate::model_download::{self, State}; +use crate::model_download::State; use crate::{EngineError, Result, Session}; fn fail(what: &str, e: impl std::fmt::Display) -> EngineError { @@ -129,8 +129,16 @@ fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result { /// Install the model in `src`. A model that passes its self-test becomes the one in use (`activate`, the default): installing /// a model is how someone says they want it. `verified` is for a download whose hash was already checked. -fn install_file(s: &mut Session, c: &str, src: &Path, acknowledged: bool, activate: bool, verified: bool) -> Result { - let dir = models_dir(s, c)?; +fn install_file( + dir: &Path, + loader: &crate::denoise::Loader, + run_on: RunOn, + accepted_at: String, + c: &str, + src: &Path, + acknowledged: bool, + verified: bool, +) -> Result<(Value, Arc)> { let ident = identify(src, c, None)?; let m = ident.manifest.clone(); manifest::validate(&m).map_err(|e| bad(c, e.to_string()))?; @@ -141,7 +149,11 @@ fn install_file(s: &mut Session, c: &str, src: &Path, acknowledged: bool, activa )); } let home = dir.join(&m.id); + if home.join("model.onnx").exists() { + return Err(bad(c, "that model is already installed; remove it before installing a replacement")); + } std::fs::create_dir_all(&home).map_err(|e| fail("could not create the model's folder", e))?; + let mut cleanup = InstallCleanup { home: home.clone(), armed: true }; let (part, final_path) = (home.join("model.onnx.part"), home.join("model.onnx")); let _ = std::fs::remove_file(&part); let placed: Result<()> = match ident.entry { @@ -162,26 +174,24 @@ fn install_file(s: &mut Session, c: &str, src: &Path, acknowledged: bool, activa return Err(e); } }; - let _ = std::fs::remove_file(&final_path); - if let Err(e) = std::fs::rename(&part, &final_path) { - let _ = std::fs::remove_file(&part); - return Err(fail("could not finish the copy", e)); - } - // a model that does not work is never left installed - let run_on = s.denoise.settings.run_on(); - let test = (s.denoise.loader)(&final_path, &m).and_then(|model| model.self_test(run_on)); - let test = match test { + // Test the staged file first. A failed replacement preserves the installed model. + let model = loader(&part, &m).map_err(|e| bad(c, e))?; + let test = match model.self_test(run_on) { Ok(t) => t, Err(e) => { - let _ = std::fs::remove_file(&final_path); - let _ = std::fs::remove_file(home.join("denoise-model.json")); - let _ = std::fs::remove_dir(&home); + let _ = std::fs::remove_file(&part); return Err(bad(c, e)); } }; + // An installed id is immutable: installing again must never destroy a usable model. + if final_path.exists() { + let _ = std::fs::remove_file(&part); + return Err(bad(c, "that model is already installed; remove it before installing a replacement")); + } + std::fs::rename(&part, &final_path).map_err(|e| fail("could not finish the copy", e))?; write_atomic(&home.join("denoise-model.json"), &serde_json::to_vec_pretty(&m).map_err(|e| fail("manifest", e))?).map_err(EngineError::Other)?; let accepted = json!({ - "acceptedAt": (s.clock)(), + "acceptedAt": accepted_at, "licence": m.licence.name, "commercial": m.licence.commercial, "fileName": ident.file_name, @@ -189,26 +199,116 @@ fn install_file(s: &mut Session, c: &str, src: &Path, acknowledged: bool, activa "selfTest": test, }); write_atomic(&home.join("installed.json"), &serde_json::to_vec_pretty(&accepted).map_err(|e| fail("record", e))?).map_err(EngineError::Other)?; - // a file that was downloaded and waited for the user's acceptance has served: the installed copy is the model now - if src.parent() == Some(dir.join(model_download::STAGING).as_path()) { - let _ = std::fs::remove_file(src); - s.denoise.downloads.staged_file_used(src); + cleanup.armed = false; + Ok((json!({"installed": row(&m, known::find(&m.id).and_then(|k| host_of(&k)), true, false, &accepted), "model": m.id}), model)) +} + +/// Removes only files created for this new installation, including on a worker panic. +struct InstallCleanup { + home: PathBuf, + armed: bool, +} +impl Drop for InstallCleanup { + fn drop(&mut self) { + if self.armed { + for name in ["model.onnx.part", "model.onnx", "denoise-model.json", "installed.json"] { + let _ = std::fs::remove_file(self.home.join(name)); + } + let _ = std::fs::remove_dir(&self.home); + } } +} + +pub(crate) struct InstallJob { + id: String, + activate: bool, + result: mpsc::Receiver), String>>, +} + +fn activate_install(s: &mut Session, result: &Value, model: Arc, activate: bool) -> Result { + let dir = models_dir(s, "denoise.models.install")?; + let id = result["installed"]["id"].as_str().ok_or_else(|| bad("denoise.models.install", "missing installed id"))?; let mut st = read_settings(&dir); if activate { - st.model = Some(m.id.clone()); + st.model = Some(id.to_string()); write_settings(&dir, &st).map_err(EngineError::Other)?; } s.denoise.touch(); s.denoise_refresh_active(true); - let host = known::find(&m.id).and_then(|k| host_of(&k)); - Ok(json!({"installed": row(&m, host, true, st.model.as_deref() == Some(m.id.as_str()), &accepted), "model": st.model})) + if let Some(a) = &s.denoise.active + && a.id == id + { + *a.model.lock().unwrap_or_else(PoisonError::into_inner) = Some(model); + } + let mut v = result.clone(); + v["installed"]["selected"] = json!(st.model.as_deref() == Some(id)); + v["model"] = json!(st.model); + Ok(v) +} + +fn start_install(s: &mut Session, src: PathBuf, id: String, activate: bool, verified: bool) -> Result { + const C: &str = "denoise.models.install"; + if s.denoise.install.is_some() { + return Err(bad(C, "another model is being installed")); + } + let dir = models_dir(s, C)?; + let loader = s.denoise.loader.clone(); + let run_on = s.denoise.settings.run_on(); + let at = (s.clock)(); + let (tx, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("denoise-model-install".into()) + .stack_size(16 * 1024 * 1024) + .spawn(move || { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _held = crate::memory::work_gate().acquire(512 * 1024 * 1024); + install_file(&dir, &loader, run_on, at, C, &src, true, verified).map_err(|e| plain(&e)) + })) + .unwrap_or_else(|_| Err("the model installation stopped unexpectedly".into())); + if verified { + let _ = std::fs::remove_file(&src); + } + let _ = tx.send(outcome); + }) + .map_err(|e| bad(C, format!("could not start installation: {e}")))?; + s.denoise.install = Some(InstallJob { id: id.clone(), activate, result: rx }); + s.denoise.downloads.set_outcome(&id, State::Installing); + Ok(json!({"started": id})) } fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "denoise.models.install"; let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; - install_file(s, C, Path::new(path), bool_or(p, "acknowledged", false), bool_or(p, "activate", true), false) + let acknowledged = bool_or(p, "acknowledged", false); + let activate = bool_or(p, "activate", true); + if !acknowledged { + return Err(bad(C, "the licence has not been accepted: show its terms first")); + } + if bool_or(p, "background", false) { + return start_install(s, PathBuf::from(path), "local-install".into(), activate, false); + } + if s.denoise.install.is_some() { + return Err(bad(C, "another model is being installed")); + } + let (result, model) = + install_file(&models_dir(s, C)?, &s.denoise.loader, s.denoise.settings.run_on(), (s.clock)(), C, Path::new(path), acknowledged, false)?; + activate_install(s, &result, model, activate) +} + +/// Read only the small adjacent manifest; hashing and execution happen in the install worker. +fn inspect(s: &mut Session, p: &Value) -> Result { + const C: &str = "denoise.models.inspect"; + let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + let src = Path::new(path); + if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) { + return Err(bad(C, "choose an .onnx file with denoise-model.json beside it")); + } + let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES) + .ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?; + let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?; + let dir = models_dir(s, C)?; + Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m, + "sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()})) } /// What an error says, without the command it came from (for a line shown to the user). @@ -222,17 +322,31 @@ fn plain(e: &EngineError) -> String { /// Install every download that has arrived: each was accepted when it was started, so it is installed and chosen without /// another question. Called by `denoise.models.downloads` and by every frame's `denoise.pump`. pub(crate) fn finish_downloads(s: &mut Session) { - const C: &str = "denoise.models.download"; - for (id, path, _sha) in s.denoise.downloads.finished() { - match install_file(s, C, &path, true, true, true) { - Ok(_) => s.denoise.downloads.set_outcome(&id, State::Installed), - Err(e) => { - // a file that cannot be installed is of no use to anyone: throw it away, say why - let _ = std::fs::remove_file(&path); - s.denoise.downloads.set_outcome(&id, State::Failed(plain(&e))); - } + if let Some(job) = &s.denoise.install { + let outcome = match job.result.try_recv() { + Ok(v) => Some(v), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(Err("the installation worker stopped".into())), + }; + if let Some(outcome) = outcome + && let Some(job) = s.denoise.install.take() + { + let state = match outcome { + Ok((v, model)) => match activate_install(s, &v, model, job.activate) { + Ok(_) => State::Installed, + Err(e) => State::Failed(plain(&e)), + }, + Err(e) => State::Failed(e), + }; + s.denoise.downloads.set_outcome(&job.id, state); } } + if s.denoise.install.is_none() + && let Some((id, path, _sha)) = s.denoise.downloads.finished().into_iter().next() + && let Err(e) = start_install(s, path, id.clone(), true, true) + { + s.denoise.downloads.set_outcome(&id, State::Failed(plain(&e))); + } } fn download(s: &mut Session, p: &Value) -> Result { @@ -263,6 +377,7 @@ fn download_row(id: &str, state: &State) -> Value { match state { State::Running { bytes, total } => json!({"id": id, "state": "running", "bytes": bytes, "total": total, "from": from}), State::Done { path, .. } => json!({"id": id, "state": "done", "path": path.display().to_string(), "from": from}), + State::Installing => json!({"id": id, "state": "installing", "from": from}), State::Installed => json!({"id": id, "state": "installed", "from": from}), State::Failed(why) => json!({"id": id, "state": "failed", "error": why, "from": from}), State::Cancelled => json!({"id": id, "state": "cancelled", "from": from}), @@ -517,7 +632,8 @@ fn clear(s: &mut Session, _: &Value) -> Result { pub fn specs() -> Vec { vec![ cmd!(query "denoise.models.list", "Denoise Models", [], None, "{} → {dir, productsDir, model, runtime, auto, cacheGb, threads, runOn, models: [{id, name, version, licence{name, commercial, url, notice}, provenance, source, sizeBytes, sha256, tile, known, downloadHost, installed, selected, accepted}]}", always, list), - cmd!(query "denoise.models.install", "Install Denoise Model", [], None, "{path, acknowledged: true, activate?: true} → {installed, model} — install a denoise model from a file: an .onnx with a denoise-model.json beside it, or an archive LightCraft knows (the darktable `.dtmodel`). `acknowledged` must be true: the user has been shown the model's licence and accepted it. Unless `activate` is false it becomes the model in use", always, install), + cmd!(query "denoise.models.inspect", "Inspect Denoise Model", [], None, "{path} → model terms from an adjacent manifest; never executes the file", always, inspect), + cmd!(query "denoise.models.install", "Install Denoise Model", [], None, "{path, acknowledged: true, activate?: true, background?: false} → {installed, model} or {started} — install a denoise model from a file: an .onnx with a denoise-model.json beside it, or an archive LightCraft knows (the darktable `.dtmodel`). `acknowledged` must be true: the user has been shown the model's licence and accepted it. Unless `activate` is false it becomes the model in use", always, install), cmd!(query "denoise.models.download", "Download Denoise Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a model LightCraft has a pinned address for (see `downloadHost` in the list) in the background over HTTPS with lightcraft-fetch. `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, installed and chosen by itself; `denoise.models.downloads` shows how far it is", always, download), cmd!(query "denoise.models.downloads", "Denoise Model Downloads", [], None, "{} → {running, downloads: [{id, state: running | done | installed | failed | cancelled, bytes, total, error, from}]} — also installs any download that has arrived", always, downloads), cmd!(query "denoise.models.downloadCancel", "Cancel Denoise Model Download", [], None, "{id} → {discarded} — stop a download, or delete a finished one that was not installed", always, download_cancel), diff --git a/crates/engine/src/denoise.rs b/crates/engine/src/denoise.rs index 37d79dcd..01bcdc78 100644 --- a/crates/engine/src/denoise.rs +++ b/crates/engine/src/denoise.rs @@ -98,6 +98,10 @@ pub(crate) trait Model: Send + Sync { /// Check it does something sensible: the test's result, or why not. Unless `run_on` is the processor the graphics /// card is set up and checked too, and the result says where the work will run. fn self_test(&self, run_on: RunOn) -> Result; + /// Conservative scratch bound for an unknown runner. + fn bytes_per_tile(&self) -> usize { + 512 * 1024 * 1024 + } /// Where the tiles run: `{kind: "gpu", adapter}`, `{kind: "cpu", reason?}` or `{kind: "pending"}` (the card is not /// set up yet), with the card's and the processor's time for the check tile (`cardMs`, `cpuMs`) once both were /// timed. Never starts anything. @@ -215,7 +219,7 @@ fn set_up_card( running.push(marker.to_path_buf()); SetUpEnded(marker.to_path_buf()) }; - let started = std::time::Instant::now(); + let started = web_time::Instant::now(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::Builder::new() .name("denoise-gpu-setup".into()) @@ -241,10 +245,10 @@ fn card_tile_ms(runner: &dyn TileRunner, input: &[f32]) -> f64 { if runner.run(input).is_err() { return f64::INFINITY; } - let began = std::time::Instant::now(); + let began = web_time::Instant::now(); let mut best = f64::INFINITY; for _ in 0..3 { - let one = std::time::Instant::now(); + let one = web_time::Instant::now(); if runner.run(input).is_err() { break; } @@ -268,7 +272,7 @@ fn make_gpu(cpu: &lightcraft_denoise::runtime::CpuRunner, path: &Path, tile: usi Ok((runner, got, card_ms)) }; let on_cpu = || { - let started = std::time::Instant::now(); + let started = web_time::Instant::now(); (cpu.run(&input), started.elapsed().as_secs_f64() * 1000.0) }; let ((runner, got, card_ms), (want, cpu_ms)) = set_up_card(&path.with_file_name(GPU_SETUP_MARKER), GPU_SETUP_LIMIT, card, on_cpu)?; @@ -351,6 +355,9 @@ impl TileRunner for Network { #[cfg(feature = "denoise")] impl Model for Network { + fn bytes_per_tile(&self) -> usize { + self.cpu.bytes_per_tile() + } fn runner(&self, run_on: RunOn, threads: usize) -> (&dyn TileRunner, usize) { let threads = threads.max(1); self.last_threads.store(threads, Ordering::Relaxed); @@ -689,7 +696,7 @@ pub(crate) fn make_product(spec: &JobSpec, progress: Option<&Progress>) -> Resul if product::is_current(&spec.product, &spec.key) { return Ok(false); } - let began = std::time::Instant::now(); + let began = web_time::Instant::now(); let cancelled = || progress.is_some_and(|p| p.cancel.load(Ordering::Relaxed)); if cancelled() { return Err(MakeError::Cancelled); @@ -726,7 +733,13 @@ pub(crate) fn make_product(spec: &JobSpec, progress: Option<&Progress>) -> Resul } let decoded = began.elapsed(); let model = loaded(spec)?; - let (runner, parallel) = model.runner(spec.run_on, spec.parallel.max(1)); + // Packed input, returned RGB and the preceding group's RGB overlap while tiles run. + let scratch = + model.bytes_per_tile().saturating_add(128usize.saturating_mul(spec.manifest.tile as usize).saturating_mul(spec.manifest.tile as usize)); + let (_, budget) = crate::memory::work_gate().usage(); + let parallel = spec.parallel.clamp(1, 4).min((budget / scratch.max(1)).max(1)); + let _held = crate::memory::work_gate().acquire(mosaic.data.len().saturating_mul(20).saturating_add(scratch.saturating_mul(parallel))); + let (runner, parallel) = model.runner(spec.run_on, parallel); let params = Params { tile: spec.manifest.tile as usize, overlap: spec.manifest.overlap as usize, @@ -859,6 +872,7 @@ pub(crate) struct State { pub models_dir: Option, /// Models being downloaded at the user's request. pub downloads: Downloads, + pub(crate) install: Option, /// How a model is loaded (tests put another in). pub loader: Loader, pub(crate) active: Option, @@ -883,6 +897,7 @@ impl Default for State { State { models_dir: None, downloads: Downloads::default(), + install: None, loader: default_loader(), active: None, active_seen: None, @@ -1604,7 +1619,7 @@ mod gpu_tests { fn a_set_up_that_takes_too_long_is_given_up_on() { let dir = folder("slowsetup"); let marker = dir.join(GPU_SETUP_MARKER); - let started = std::time::Instant::now(); + let started = web_time::Instant::now(); let slow = || -> Result<(), String> { std::thread::sleep(Duration::from_millis(600)); Ok(()) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 5ef823db..2052579d 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -309,8 +309,11 @@ fn load_bytes_now( let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); // camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied, // fitted to `max_edge` and upright (the same for the plain and the denoised picture) - let finish = |mut img: Rgb32f| -> Rgb32f { + let finish = |mut img: Rgb32f| { + let mut stages = vec![("develop", t0.elapsed())]; + stages.push(("transform", t0.elapsed())); lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP); + stages.push(("highlights", t0.elapsed())); img.map_in_place(|p| { let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; let rgb = [ @@ -324,21 +327,27 @@ fn load_bytes_now( }; hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) }); + stages.push(("colour", t0.elapsed())); let img = fit(&img, max_edge, max_edge, Filter::Box); - img.into_oriented(raw.orientation) + stages.push(("fit", t0.elapsed())); + let img = img.into_oriented(raw.orientation); + stages.push(("orient", t0.elapsed())); + (img, stages) }; - let develop_ms = t0.elapsed(); - let img = finish(img); - let twin = twin.map(finish); + let (img, stages) = finish(img); if lightcraft_pipeline::profiling() { - let finish_ms = (t0.elapsed() - develop_ms).as_secs_f64() * 1e3; - eprintln!( - "[profile] raw source {}×{} (max {max_edge}): develop {:.1} ms, finish {finish_ms:.1} ms (after decode)", - img.width, - img.height, - develop_ms.as_secs_f64() * 1e3 - ); + let mut prev = std::time::Duration::ZERO; + let parts: Vec = stages + .iter() + .map(|(n, t)| { + let d = *t - prev; + prev = *t; + format!("{n} {:.1}", d.as_secs_f64() * 1e3) + }) + .collect(); + eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); } + let twin = twin.map(|picture| finish(picture).0); let (temp, tint) = xy_to_temp_tint(xy); let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback; let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5f298de4..00d8ab40 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,10 @@ //! run them off the UI thread. #![forbid(unsafe_code)] +// Model/cache types remain available without linking the optional inference crate. +#[cfg(not(feature = "denoise"))] +extern crate lightcraft_denoise_core as lightcraft_denoise; + pub mod availability; mod camera_preview; pub mod camera_profiles; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 479831c9..8130b5ad 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -156,10 +156,9 @@ impl Twin { Twin { image, mix: std::sync::Mutex::new(None) } } - /// Bytes held, the last mix included. + /// Bytes reserved for the twin and its lazy mix, before either cache cost can grow. fn bytes(&self) -> usize { - let mixed = self.mix.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().map_or(0, |m| m.1.data.len() * 12); - self.image.data.len() * 12 + mixed + self.image.data.len().saturating_mul(24) } } @@ -207,7 +206,7 @@ impl DecodedSource { mixed } - /// Bytes held: the picture, its denoised twin and the last mix. + /// Cache cost: the picture, its denoised twin and room for the lazy mix. pub fn bytes(&self) -> usize { self.image.data.len() * 12 + 64 + std::mem::size_of::() + self.denoised.as_ref().map_or(0, |t| t.bytes()) } @@ -1390,6 +1389,20 @@ mod thumbnail_hash_tests { mod tests { use super::*; + #[test] + fn denoise_mix_is_reserved_before_the_source_enters_the_cache() { + let mut plain = Rgb32f::new(8, 8); + plain.data.fill([1.0; 3]); + let mut source = DecodedSource::new(Arc::new(plain), None); + source.denoised = Some(Arc::new(Twin::new(Arc::new(Rgb32f::new(8, 8))))); + let reserved = source.bytes(); + assert!(reserved >= 3 * 8 * 8 * 12); + assert_eq!(source.image_for(0.5).data[0], [0.5; 3]); + assert_eq!(source.bytes(), reserved, "creating a mix cannot evade the LRU's recorded cost"); + let _ = source.image_for(0.25); + assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix"); + } + #[test] fn decoder_info_survives_render_jobs_cache_and_eviction() { let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { diff --git a/crates/engine/src/model_download.rs b/crates/engine/src/model_download.rs index f07c373d..98707f78 100644 --- a/crates/engine/src/model_download.rs +++ b/crates/engine/src/model_download.rs @@ -35,6 +35,7 @@ pub enum State { sha256: String, }, /// Installed and in use: nothing more to do but tell the user. + Installing, Installed, Failed(String), Cancelled, @@ -107,7 +108,7 @@ impl Downloads { job.cancel.store(true, Ordering::Relaxed); match job.get() { // the thread notices, deletes the partial file and marks itself cancelled - State::Running { .. } => {} + State::Running { .. } | State::Installing => {} State::Done { path, .. } => { let _ = std::fs::remove_file(path); self.jobs.remove(id); @@ -119,11 +120,6 @@ impl Downloads { true } - /// The staged file at `path` has been installed (its copy is the model now) or is no longer wanted. - pub fn staged_file_used(&mut self, path: &Path) { - self.jobs.retain(|_, j| !matches!(j.get(), State::Done { path: p, .. } if p == path)); - } - /// Downloads that have arrived and been checked, ready to install: (model id, staged file, its SHA-256). pub fn finished(&self) -> Vec<(String, PathBuf, String)> { let mut out: Vec<_> = self @@ -157,7 +153,7 @@ impl Drop for Downloads { for job in self.jobs.values() { job.cancel.store(true, Ordering::Relaxed); } - let started = std::time::Instant::now(); + let started = web_time::Instant::now(); while self.running() && started.elapsed() < Duration::from_secs(1) { std::thread::sleep(Duration::from_millis(10)); } @@ -336,7 +332,7 @@ mod tests { let mut d = Downloads::default(); d.jobs.insert("m".into(), shared.clone()); assert!(d.running()); - let started = std::time::Instant::now(); + let started = web_time::Instant::now(); drop(d); assert!(started.elapsed() < Duration::from_millis(900), "quitting must not wait for a transfer to finish"); assert_eq!(shared.get(), State::Cancelled); diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index 172f9082..ef87747f 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -81,6 +81,65 @@ fn model_files(dir: &Path, id: &str) -> PathBuf { src.join("model.onnx") } +#[test] +fn model_installation_keeps_the_frame_pump_responsive_and_reuses_the_loaded_model() { + use std::sync::{Mutex, mpsc}; + use std::time::Duration; + let mut x = setup("background-install", false); + let path = model_files(&x.dir, "stand-in"); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let release_rx = Mutex::new(release_rx); + let runs = x.runs.clone(); + let loads = Arc::new(AtomicUsize::new(0)); + let worker_loads = loads.clone(); + x.s.denoise.loader = Arc::new(move |_, _| { + worker_loads.fetch_add(1, Ordering::Relaxed); + entered_tx.send(()).map_err(|e| e.to_string())?; + release_rx.lock().unwrap().recv_timeout(Duration::from_secs(3)).map_err(|e| e.to_string())?; + Ok(Arc::new(Dim { runs: runs.clone() })) + }); + let info = x.s.execute("denoise.models.inspect", &json!({"path": path})).unwrap(); + assert_eq!(info["model"]["id"], "stand-in"); + assert_eq!(loads.load(Ordering::Relaxed), 0, "showing terms never executes the model"); + let r = x.s.execute("denoise.models.install", &json!({"path": path, "acknowledged": true, "background": true})).unwrap(); + assert_eq!(r["started"], "local-install"); + entered_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + // The loader is blocked: the frame pump must still return, reporting installation in progress. + let state = x.s.execute("denoise.models.downloads", &json!({})).unwrap(); + assert_eq!(state["downloads"][0]["state"], "installing"); + assert_eq!(x.s.execute("denoise.pump", &json!({"pace": "pause"})).unwrap()["active"], false); + assert!(x.s.execute("denoise.models.install", &json!({"path": path, "acknowledged": true, "background": true})).is_err()); + release_tx.send(()).unwrap(); + for _ in 0..600 { + let v = x.s.execute("denoise.models.downloads", &json!({})).unwrap(); + if v["downloads"][0]["state"] == "installed" { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(x.s.execute("denoise.models.downloads", &json!({})).unwrap()["downloads"][0]["state"], "installed"); + let a = x.s.denoise.active.as_ref().unwrap(); + assert!(a.model.lock().unwrap().is_some(), "the worker's tested model is reused"); + assert_eq!(loads.load(Ordering::Relaxed), 1); + drop(x.s); + let _ = std::fs::remove_dir_all(x.dir); +} + +#[test] +fn failed_model_replacement_preserves_the_installed_model() { + let mut x = setup("replacement", true); + let installed = x.dir.join("denoise-models/stand-in/model.onnx"); + let original = std::fs::read(&installed).unwrap(); + let source = model_files(&x.dir, "stand-in"); + std::fs::write(&source, "a different broken model").unwrap(); + assert!(x.s.execute("denoise.models.install", &json!({"path": source, "acknowledged": true})).is_err()); + assert_eq!(std::fs::read(installed).unwrap(), original); + assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["model"], "stand-in"); + drop(x.s); + let _ = std::fs::remove_dir_all(x.dir); +} + /// A Bayer DNG that is a smooth ramp, big enough for a 64-cell tile and a second one. fn ramp_dng(w: usize, h: usize, cfa: &str) -> Vec { let data: Vec = (0..w * h).map(|i| 600 + ((i % w) * 30 + (i / w) * 35) as u16).collect(); @@ -339,7 +398,8 @@ fn a_model_that_fails_fails_the_export_and_is_not_retried_by_the_queue() { let mut x = setup("fails", true); let id = photo(&x.s); x.s.denoise.loader = Arc::new(|_, _| Err("the model went away".into())); - // (the model chosen is read afresh: it was loaded by the install's self-test only) + // Simulate loss of the in-memory model; successful installation now retains its tested instance. + *x.s.denoise.active.as_ref().unwrap().model.lock().unwrap() = None; set_amount(&mut x.s, 80.0); let o = ExportOptions::from_json(&json!({"format": "png", "width": 100, "height": 100})); let e = export_photo(&mut x.s, id, &o, 1).err().unwrap(); diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 314f69c8..b19acf54 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -9,7 +9,7 @@ description = "LightCraft GPU develop pipeline: wgpu compute (WGSL) twin of the workspace = true [dependencies] -lightcraft-denoise = { workspace = true } +lightcraft-denoise = { workspace = true, optional = true } lightcraft-geom = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } @@ -24,5 +24,9 @@ wgpu = { workspace = true } pollster = { workspace = true } bytemuck = { workspace = true } +[features] +default = [] +denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"] + [dev-dependencies] lightcraft-scenes = { workspace = true } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index b4e32ba9..9c234964 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -29,7 +29,7 @@ use lightcraft_raster::Rgb32f; pub mod backend; #[cfg(not(target_arch = "wasm32"))] mod ctx; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))] pub mod nn; #[cfg(not(target_arch = "wasm32"))] mod params; @@ -149,7 +149,7 @@ pub fn enabled() -> bool { /// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the /// environment switches and the rendering preference, but not a render-device failure (the devices are separate). -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))] pub(crate) fn switched_off() -> Option { if env_disabled() { return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into()); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 7836d96c..f36ac02f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,8 +25,8 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } -lightcraft-denoise = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index af10202f..12672394 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -187,5 +187,24 @@ "Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft", "{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)", ", {} star{}": ", Sterne: {}{:.0}", - ", {} label": ", Markierung: {}" + ", {} label": ", Markierung: {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Running on the processor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", + "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} of {} from {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Trained on: {provenance}", + " · {queued} waiting": " · {queued} waiting", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", + "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", + "Running on {}.": "Running on {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 2a472186..2465cd8e 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1797,5 +1797,94 @@ "How to install the model": "So installierst du das Modell", "Install the SAM 3 Model": "SAM-3-Modell installieren", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.", - "Open Log Folder": "Protokollordner öffnen" + "Open Log Folder": "Protokollordner öffnen", + "AI Denoise": "AI Denoise", + "AI Denoise Model": "AI Denoise Model", + "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", + "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", + "Accept & Download": "Accept & Download", + "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", + "Cache": "Cache", + "Checking and installing the model…": "Checking and installing the model…", + "Checking…": "Checking…", + "Clear cache": "Clear cache", + "Denoise Models": "Denoise Models", + "Denoise Settings": "Denoise Settings", + "Denoise all photos with an Amount": "Denoise all photos with an Amount", + "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", + "Failed its last test": "Failed its last test", + "Graphics card": "Graphics card", + "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", + "Inspect Denoise Model": "Inspect Denoise Model", + "Install Denoise Model": "Install Denoise Model", + "Install from file…": "Install from file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", + "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", + "Limit": "Limit", + "Make it now": "Make it now", + "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", + "Next up for AI Denoise…": "Next up for AI Denoise…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", + "Open page": "Open page", + "Open the model's page": "Open the model's page", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", + "Processor": "Processor", + "Set up AI Denoise…": "Set up AI Denoise…", + "Speed": "Speed", + "Starting…": "Starting…", + "Tested and works on this computer": "Tested and works on this computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", + "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", + "The processor does the work.": "The processor does the work.", + "There is no model file to install": "There is no model file to install", + "This build cannot run denoise models.": "This build cannot run denoise models.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", + "This file cannot be used yet": "This file cannot be used yet", + "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", + "Try again": "Try again", + "Try the graphics card again": "Try the graphics card again", + "Turn off": "Turn off", + "Unknown licence": "Unknown licence", + "Use": "Use", + "Waiting to start…": "Waiting to start…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", + "cached picture": "cached picture", + "cached pictures": "cached pictures", + "commercial use allowed": "commercial use allowed", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in use", + "installed": "installed", + "makes AI Denoise available": "makes AI Denoise available", + "non-commercial use only": "non-commercial use only", + "photo has its denoised picture.": "photo has its denoised picture.", + "photo waiting": "photo waiting", + "photos have their denoised picture.": "photos have their denoised picture.", + "photos waiting": "photos waiting", + "starts using it for AI Denoise": "starts using it for AI Denoise", + "terms unclear": "terms unclear", + "the graphics card": "the graphics card", + "the graphics card is not used": "the graphics card is not used", + "Cancel Denoise": "Cancel Denoise", + "Cancel Denoise Model Download": "Cancel Denoise Model Download", + "Choose Denoise Model": "Choose Denoise Model", + "Clear Denoise Cache": "Clear Denoise Cache", + "Denoise Model Downloads": "Denoise Model Downloads", + "Denoise Photos": "Denoise Photos", + "Denoise Pump": "Denoise Pump", + "Denoise Status": "Denoise Status", + "Download Denoise Model": "Download Denoise Model", + "Remove Denoise Model": "Remove Denoise Model", + "Test Denoise Model": "Test Denoise Model", + "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", + "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", + "Cancel pending action": "Cancel pending action", + "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", + "Pending photo action cancelled": "Pending photo action cancelled", + "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", + "ONNX model": "ONNX model", + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index b67d6720..54418c76 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -187,5 +187,24 @@ "Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) de nuevo", "{} label (click more labels to show any of them)": "Etiqueta {} (hacer clic en más etiquetas para mostrar cualquiera de ellas)", ", {} star{}": ", {} estrella{}", - ", {} label": ", etiqueta {}" + ", {} label": ", etiqueta {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Running on the processor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", + "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} of {} from {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Trained on: {provenance}", + " · {queued} waiting": " · {queued} waiting", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", + "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", + "Running on {}.": "Running on {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c0986c19..032412c5 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1642,5 +1642,94 @@ "Captured from": "Capturadas desde", "Captured until": "Capturadas hasta", "Only": "Solo", - "Open Log Folder": "Abrir carpeta de registros" + "Open Log Folder": "Abrir carpeta de registros", + "AI Denoise": "AI Denoise", + "AI Denoise Model": "AI Denoise Model", + "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", + "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", + "Accept & Download": "Accept & Download", + "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", + "Cache": "Cache", + "Checking and installing the model…": "Checking and installing the model…", + "Checking…": "Checking…", + "Clear cache": "Clear cache", + "Denoise Models": "Denoise Models", + "Denoise Settings": "Denoise Settings", + "Denoise all photos with an Amount": "Denoise all photos with an Amount", + "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", + "Failed its last test": "Failed its last test", + "Graphics card": "Graphics card", + "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", + "Inspect Denoise Model": "Inspect Denoise Model", + "Install Denoise Model": "Install Denoise Model", + "Install from file…": "Install from file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", + "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", + "Limit": "Limit", + "Make it now": "Make it now", + "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", + "Next up for AI Denoise…": "Next up for AI Denoise…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", + "Open page": "Open page", + "Open the model's page": "Open the model's page", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", + "Processor": "Processor", + "Set up AI Denoise…": "Set up AI Denoise…", + "Speed": "Speed", + "Starting…": "Starting…", + "Tested and works on this computer": "Tested and works on this computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", + "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", + "The processor does the work.": "The processor does the work.", + "There is no model file to install": "There is no model file to install", + "This build cannot run denoise models.": "This build cannot run denoise models.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", + "This file cannot be used yet": "This file cannot be used yet", + "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", + "Try again": "Try again", + "Try the graphics card again": "Try the graphics card again", + "Turn off": "Turn off", + "Unknown licence": "Unknown licence", + "Use": "Use", + "Waiting to start…": "Waiting to start…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", + "cached picture": "cached picture", + "cached pictures": "cached pictures", + "commercial use allowed": "commercial use allowed", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in use", + "installed": "installed", + "makes AI Denoise available": "makes AI Denoise available", + "non-commercial use only": "non-commercial use only", + "photo has its denoised picture.": "photo has its denoised picture.", + "photo waiting": "photo waiting", + "photos have their denoised picture.": "photos have their denoised picture.", + "photos waiting": "photos waiting", + "starts using it for AI Denoise": "starts using it for AI Denoise", + "terms unclear": "terms unclear", + "the graphics card": "the graphics card", + "the graphics card is not used": "the graphics card is not used", + "Cancel Denoise": "Cancel Denoise", + "Cancel Denoise Model Download": "Cancel Denoise Model Download", + "Choose Denoise Model": "Choose Denoise Model", + "Clear Denoise Cache": "Clear Denoise Cache", + "Denoise Model Downloads": "Denoise Model Downloads", + "Denoise Photos": "Denoise Photos", + "Denoise Pump": "Denoise Pump", + "Denoise Status": "Denoise Status", + "Download Denoise Model": "Download Denoise Model", + "Remove Denoise Model": "Remove Denoise Model", + "Test Denoise Model": "Test Denoise Model", + "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", + "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", + "Cancel pending action": "Cancel pending action", + "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", + "Pending photo action cancelled": "Pending photo action cancelled", + "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", + "ONNX model": "ONNX model", + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index e462871c..b0e0f2f9 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -187,5 +187,24 @@ "Moved; {} photo(s) relinked": "移動しました。{}枚の写真の参照先を更新しました", "{} label (click more labels to show any of them)": "{}ラベル(ほかのラベルもクリックすると、いずれかに一致する写真を表示)", ", {} star{}": "、星{}つ{:.0}", - ", {} label": "、{}ラベル" + ", {} label": "、{}ラベル", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Running on the processor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", + "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} of {} from {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Trained on: {provenance}", + " · {queued} waiting": " · {queued} waiting", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", + "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", + "Running on {}.": "Running on {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 31c61a7a..dc22711b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1642,5 +1642,94 @@ "How to install the model": "モデルのインストール方法", "Install the SAM 3 Model": "SAM 3モデルのインストール", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。", - "Open Log Folder": "ログフォルダーを開く" + "Open Log Folder": "ログフォルダーを開く", + "AI Denoise": "AI Denoise", + "AI Denoise Model": "AI Denoise Model", + "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", + "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", + "Accept & Download": "Accept & Download", + "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", + "Cache": "Cache", + "Checking and installing the model…": "Checking and installing the model…", + "Checking…": "Checking…", + "Clear cache": "Clear cache", + "Denoise Models": "Denoise Models", + "Denoise Settings": "Denoise Settings", + "Denoise all photos with an Amount": "Denoise all photos with an Amount", + "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", + "Failed its last test": "Failed its last test", + "Graphics card": "Graphics card", + "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", + "Inspect Denoise Model": "Inspect Denoise Model", + "Install Denoise Model": "Install Denoise Model", + "Install from file…": "Install from file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", + "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", + "Limit": "Limit", + "Make it now": "Make it now", + "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", + "Next up for AI Denoise…": "Next up for AI Denoise…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", + "Open page": "Open page", + "Open the model's page": "Open the model's page", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", + "Processor": "Processor", + "Set up AI Denoise…": "Set up AI Denoise…", + "Speed": "Speed", + "Starting…": "Starting…", + "Tested and works on this computer": "Tested and works on this computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", + "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", + "The processor does the work.": "The processor does the work.", + "There is no model file to install": "There is no model file to install", + "This build cannot run denoise models.": "This build cannot run denoise models.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", + "This file cannot be used yet": "This file cannot be used yet", + "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", + "Try again": "Try again", + "Try the graphics card again": "Try the graphics card again", + "Turn off": "Turn off", + "Unknown licence": "Unknown licence", + "Use": "Use", + "Waiting to start…": "Waiting to start…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", + "cached picture": "cached picture", + "cached pictures": "cached pictures", + "commercial use allowed": "commercial use allowed", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in use", + "installed": "installed", + "makes AI Denoise available": "makes AI Denoise available", + "non-commercial use only": "non-commercial use only", + "photo has its denoised picture.": "photo has its denoised picture.", + "photo waiting": "photo waiting", + "photos have their denoised picture.": "photos have their denoised picture.", + "photos waiting": "photos waiting", + "starts using it for AI Denoise": "starts using it for AI Denoise", + "terms unclear": "terms unclear", + "the graphics card": "the graphics card", + "the graphics card is not used": "the graphics card is not used", + "Cancel Denoise": "Cancel Denoise", + "Cancel Denoise Model Download": "Cancel Denoise Model Download", + "Choose Denoise Model": "Choose Denoise Model", + "Clear Denoise Cache": "Clear Denoise Cache", + "Denoise Model Downloads": "Denoise Model Downloads", + "Denoise Photos": "Denoise Photos", + "Denoise Pump": "Denoise Pump", + "Denoise Status": "Denoise Status", + "Download Denoise Model": "Download Denoise Model", + "Remove Denoise Model": "Remove Denoise Model", + "Test Denoise Model": "Test Denoise Model", + "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", + "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", + "Cancel pending action": "Cancel pending action", + "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", + "Pending photo action cancelled": "Pending photo action cancelled", + "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", + "ONNX model": "ONNX model", + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 59bec59d..ab64e082 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -187,5 +187,24 @@ "Moved; {} photo(s) relinked": "Movida; {} foto(s) vinculada(s) novamente", "{} label (click more labels to show any of them)": "Rótulo {} (clique em mais rótulos para mostrar qualquer um deles)", ", {} star{}": ", {} estrela{}", - ", {} label": ", rótulo {}" + ", {} label": ", rótulo {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Running on the processor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", + "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} of {} from {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Trained on: {provenance}", + " · {queued} waiting": " · {queued} waiting", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", + "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", + "Running on {}.": "Running on {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 03ae0773..521da1b1 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1642,5 +1642,94 @@ "≤ at most": "≤ no máximo", "≥ at least": "≥ no mínimo", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "AI Denoise": "AI Denoise", + "AI Denoise Model": "AI Denoise Model", + "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", + "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", + "Accept & Download": "Accept & Download", + "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", + "Cache": "Cache", + "Checking and installing the model…": "Checking and installing the model…", + "Checking…": "Checking…", + "Clear cache": "Clear cache", + "Denoise Models": "Denoise Models", + "Denoise Settings": "Denoise Settings", + "Denoise all photos with an Amount": "Denoise all photos with an Amount", + "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", + "Failed its last test": "Failed its last test", + "Graphics card": "Graphics card", + "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", + "Inspect Denoise Model": "Inspect Denoise Model", + "Install Denoise Model": "Install Denoise Model", + "Install from file…": "Install from file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", + "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", + "Limit": "Limit", + "Make it now": "Make it now", + "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", + "Next up for AI Denoise…": "Next up for AI Denoise…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", + "Open page": "Open page", + "Open the model's page": "Open the model's page", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", + "Processor": "Processor", + "Set up AI Denoise…": "Set up AI Denoise…", + "Speed": "Speed", + "Starting…": "Starting…", + "Tested and works on this computer": "Tested and works on this computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", + "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", + "The processor does the work.": "The processor does the work.", + "There is no model file to install": "There is no model file to install", + "This build cannot run denoise models.": "This build cannot run denoise models.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", + "This file cannot be used yet": "This file cannot be used yet", + "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", + "Try again": "Try again", + "Try the graphics card again": "Try the graphics card again", + "Turn off": "Turn off", + "Unknown licence": "Unknown licence", + "Use": "Use", + "Waiting to start…": "Waiting to start…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", + "cached picture": "cached picture", + "cached pictures": "cached pictures", + "commercial use allowed": "commercial use allowed", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in use", + "installed": "installed", + "makes AI Denoise available": "makes AI Denoise available", + "non-commercial use only": "non-commercial use only", + "photo has its denoised picture.": "photo has its denoised picture.", + "photo waiting": "photo waiting", + "photos have their denoised picture.": "photos have their denoised picture.", + "photos waiting": "photos waiting", + "starts using it for AI Denoise": "starts using it for AI Denoise", + "terms unclear": "terms unclear", + "the graphics card": "the graphics card", + "the graphics card is not used": "the graphics card is not used", + "Cancel Denoise": "Cancel Denoise", + "Cancel Denoise Model Download": "Cancel Denoise Model Download", + "Choose Denoise Model": "Choose Denoise Model", + "Clear Denoise Cache": "Clear Denoise Cache", + "Denoise Model Downloads": "Denoise Model Downloads", + "Denoise Photos": "Denoise Photos", + "Denoise Pump": "Denoise Pump", + "Denoise Status": "Denoise Status", + "Download Denoise Model": "Download Denoise Model", + "Remove Denoise Model": "Remove Denoise Model", + "Test Denoise Model": "Test Denoise Model", + "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", + "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", + "Cancel pending action": "Cancel pending action", + "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", + "Pending photo action cancelled": "Pending photo action cancelled", + "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", + "ONNX model": "ONNX model", + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 2beb4a5b..f3a0f647 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -187,5 +187,24 @@ "{} — show their photos": "{} — показать фото этого человека", "· {dups} already in the library": "· уже в библиотеке: {dups}", "· {n} photo{}": "· фото: {n}{:.0}", - "… and {more} more": "… и ещё {more}" + "… and {more} more": "… и ещё {more}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Running on the processor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", + "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} of {} from {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Trained on: {provenance}", + " · {queued} waiting": " · {queued} waiting", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", + "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", + "Running on {}.": "Running on {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 530cdfcb..32ea6149 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1312,5 +1312,95 @@ "of the following rules:": "из следующих условий:", "saved in": "сохранено в", "this photo": "это фото", - "− harder border · + softer border": "− жёстче край · + мягче край" + "− harder border · + softer border": "− жёстче край · + мягче край", + "AI Denoise": "AI Denoise", + "AI Denoise Model": "AI Denoise Model", + "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", + "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", + "Accept & Download": "Accept & Download", + "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", + "Cache": "Cache", + "Checking and installing the model…": "Checking and installing the model…", + "Checking…": "Checking…", + "Clear cache": "Clear cache", + "Denoise Models": "Denoise Models", + "Denoise Settings": "Denoise Settings", + "Denoise all photos with an Amount": "Denoise all photos with an Amount", + "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", + "Failed its last test": "Failed its last test", + "Graphics card": "Graphics card", + "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", + "Inspect Denoise Model": "Inspect Denoise Model", + "Install Denoise Model": "Install Denoise Model", + "Install from file…": "Install from file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", + "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", + "Limit": "Limit", + "Make it now": "Make it now", + "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", + "Models": "Models", + "Next up for AI Denoise…": "Next up for AI Denoise…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", + "Open page": "Open page", + "Open the model's page": "Open the model's page", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", + "Processor": "Processor", + "Set up AI Denoise…": "Set up AI Denoise…", + "Speed": "Speed", + "Starting…": "Starting…", + "Tested and works on this computer": "Tested and works on this computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", + "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", + "The processor does the work.": "The processor does the work.", + "There is no model file to install": "There is no model file to install", + "This build cannot run denoise models.": "This build cannot run denoise models.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", + "This file cannot be used yet": "This file cannot be used yet", + "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", + "Try again": "Try again", + "Try the graphics card again": "Try the graphics card again", + "Turn off": "Turn off", + "Unknown licence": "Unknown licence", + "Use": "Use", + "Waiting to start…": "Waiting to start…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", + "cached picture": "cached picture", + "cached pictures": "cached pictures", + "commercial use allowed": "commercial use allowed", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in use", + "installed": "installed", + "makes AI Denoise available": "makes AI Denoise available", + "non-commercial use only": "non-commercial use only", + "photo has its denoised picture.": "photo has its denoised picture.", + "photo waiting": "photo waiting", + "photos have their denoised picture.": "photos have their denoised picture.", + "photos waiting": "photos waiting", + "starts using it for AI Denoise": "starts using it for AI Denoise", + "terms unclear": "terms unclear", + "the graphics card": "the graphics card", + "the graphics card is not used": "the graphics card is not used", + "Cancel Denoise": "Cancel Denoise", + "Cancel Denoise Model Download": "Cancel Denoise Model Download", + "Choose Denoise Model": "Choose Denoise Model", + "Clear Denoise Cache": "Clear Denoise Cache", + "Denoise Model Downloads": "Denoise Model Downloads", + "Denoise Photos": "Denoise Photos", + "Denoise Pump": "Denoise Pump", + "Denoise Status": "Denoise Status", + "Download Denoise Model": "Download Denoise Model", + "Remove Denoise Model": "Remove Denoise Model", + "Test Denoise Model": "Test Denoise Model", + "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", + "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", + "Cancel pending action": "Cancel pending action", + "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", + "Pending photo action cancelled": "Pending photo action cancelled", + "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", + "ONNX model": "ONNX model", + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index eca40f8e..e32c7f66 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -187,5 +187,24 @@ "Moved; {} photo(s) relinked": "已移动;已重新链接 {0} 张照片", "{} label (click more labels to show any of them)": "{0}标签(单击更多标签可显示符合其中任一标签的照片)", ", {} star{}": ",{0} 星{1:.0}", - ", {} label": ",{0}标签" + ", {} label": ",{0}标签", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Running on the processor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", + "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} of {} from {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Trained on: {provenance}", + " · {queued} waiting": " · {queued} waiting", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", + "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", + "Running on {}.": "Running on {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index cea313d0..466f57b4 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1642,5 +1642,94 @@ "How to install the model": "如何安装模型", "Install the SAM 3 Model": "安装 SAM 3 模型", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。", - "Open Log Folder": "打开日志文件夹" + "Open Log Folder": "打开日志文件夹", + "AI Denoise": "AI Denoise", + "AI Denoise Model": "AI Denoise Model", + "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", + "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", + "Accept & Download": "Accept & Download", + "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", + "Cache": "Cache", + "Checking and installing the model…": "Checking and installing the model…", + "Checking…": "Checking…", + "Clear cache": "Clear cache", + "Denoise Models": "Denoise Models", + "Denoise Settings": "Denoise Settings", + "Denoise all photos with an Amount": "Denoise all photos with an Amount", + "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", + "Failed its last test": "Failed its last test", + "Graphics card": "Graphics card", + "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", + "Inspect Denoise Model": "Inspect Denoise Model", + "Install Denoise Model": "Install Denoise Model", + "Install from file…": "Install from file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", + "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", + "Limit": "Limit", + "Make it now": "Make it now", + "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", + "Next up for AI Denoise…": "Next up for AI Denoise…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", + "Open page": "Open page", + "Open the model's page": "Open the model's page", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", + "Processor": "Processor", + "Set up AI Denoise…": "Set up AI Denoise…", + "Speed": "Speed", + "Starting…": "Starting…", + "Tested and works on this computer": "Tested and works on this computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", + "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", + "The processor does the work.": "The processor does the work.", + "There is no model file to install": "There is no model file to install", + "This build cannot run denoise models.": "This build cannot run denoise models.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", + "This file cannot be used yet": "This file cannot be used yet", + "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", + "Try again": "Try again", + "Try the graphics card again": "Try the graphics card again", + "Turn off": "Turn off", + "Unknown licence": "Unknown licence", + "Use": "Use", + "Waiting to start…": "Waiting to start…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", + "cached picture": "cached picture", + "cached pictures": "cached pictures", + "commercial use allowed": "commercial use allowed", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in use", + "installed": "installed", + "makes AI Denoise available": "makes AI Denoise available", + "non-commercial use only": "non-commercial use only", + "photo has its denoised picture.": "photo has its denoised picture.", + "photo waiting": "photo waiting", + "photos have their denoised picture.": "photos have their denoised picture.", + "photos waiting": "photos waiting", + "starts using it for AI Denoise": "starts using it for AI Denoise", + "terms unclear": "terms unclear", + "the graphics card": "the graphics card", + "the graphics card is not used": "the graphics card is not used", + "Cancel Denoise": "Cancel Denoise", + "Cancel Denoise Model Download": "Cancel Denoise Model Download", + "Choose Denoise Model": "Choose Denoise Model", + "Clear Denoise Cache": "Clear Denoise Cache", + "Denoise Model Downloads": "Denoise Model Downloads", + "Denoise Photos": "Denoise Photos", + "Denoise Pump": "Denoise Pump", + "Denoise Status": "Denoise Status", + "Download Denoise Model": "Download Denoise Model", + "Remove Denoise Model": "Remove Denoise Model", + "Test Denoise Model": "Test Denoise Model", + "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", + "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", + "Cancel pending action": "Cancel pending action", + "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", + "Pending photo action cancelled": "Pending photo action cancelled", + "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", + "ONNX model": "ONNX model", + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 640e3eb7..182a7dc6 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -187,5 +187,24 @@ "Moved; {} photo(s) relinked": "已移動;已重新連結 {} 張照片", "{} label (click more labels to show any of them)": "{}標籤(點擊更多標籤可顯示符合其中任一標籤的照片)", ", {} star{}": ",{} 顆星{:.0}", - ", {} label": ",{}標籤" + ", {} label": ",{}標籤", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Running on the processor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", + "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} of {} from {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Trained on: {provenance}", + " · {queued} waiting": " · {queued} waiting", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", + "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", + "Running on {}.": "Running on {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 597ecbf9..c0ab6d4a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1642,5 +1642,94 @@ "How to install the model": "如何安裝模型", "Install the SAM 3 Model": "安裝 SAM 3 模型", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。", - "Open Log Folder": "開啟記錄資料夾" + "Open Log Folder": "開啟記錄資料夾", + "AI Denoise": "AI Denoise", + "AI Denoise Model": "AI Denoise Model", + "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", + "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", + "Accept & Download": "Accept & Download", + "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", + "Cache": "Cache", + "Checking and installing the model…": "Checking and installing the model…", + "Checking…": "Checking…", + "Clear cache": "Clear cache", + "Denoise Models": "Denoise Models", + "Denoise Settings": "Denoise Settings", + "Denoise all photos with an Amount": "Denoise all photos with an Amount", + "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", + "Failed its last test": "Failed its last test", + "Graphics card": "Graphics card", + "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", + "Inspect Denoise Model": "Inspect Denoise Model", + "Install Denoise Model": "Install Denoise Model", + "Install from file…": "Install from file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", + "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", + "Limit": "Limit", + "Make it now": "Make it now", + "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", + "Next up for AI Denoise…": "Next up for AI Denoise…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", + "Open page": "Open page", + "Open the model's page": "Open the model's page", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", + "Processor": "Processor", + "Set up AI Denoise…": "Set up AI Denoise…", + "Speed": "Speed", + "Starting…": "Starting…", + "Tested and works on this computer": "Tested and works on this computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", + "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", + "The processor does the work.": "The processor does the work.", + "There is no model file to install": "There is no model file to install", + "This build cannot run denoise models.": "This build cannot run denoise models.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", + "This file cannot be used yet": "This file cannot be used yet", + "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", + "Try again": "Try again", + "Try the graphics card again": "Try the graphics card again", + "Turn off": "Turn off", + "Unknown licence": "Unknown licence", + "Use": "Use", + "Waiting to start…": "Waiting to start…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", + "cached picture": "cached picture", + "cached pictures": "cached pictures", + "commercial use allowed": "commercial use allowed", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in use", + "installed": "installed", + "makes AI Denoise available": "makes AI Denoise available", + "non-commercial use only": "non-commercial use only", + "photo has its denoised picture.": "photo has its denoised picture.", + "photo waiting": "photo waiting", + "photos have their denoised picture.": "photos have their denoised picture.", + "photos waiting": "photos waiting", + "starts using it for AI Denoise": "starts using it for AI Denoise", + "terms unclear": "terms unclear", + "the graphics card": "the graphics card", + "the graphics card is not used": "the graphics card is not used", + "Cancel Denoise": "Cancel Denoise", + "Cancel Denoise Model Download": "Cancel Denoise Model Download", + "Choose Denoise Model": "Choose Denoise Model", + "Clear Denoise Cache": "Clear Denoise Cache", + "Denoise Model Downloads": "Denoise Model Downloads", + "Denoise Photos": "Denoise Photos", + "Denoise Pump": "Denoise Pump", + "Denoise Status": "Denoise Status", + "Download Denoise Model": "Download Denoise Model", + "Remove Denoise Model": "Remove Denoise Model", + "Test Denoise Model": "Test Denoise Model", + "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", + "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", + "Cancel pending action": "Cancel pending action", + "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", + "Pending photo action cancelled": "Pending photo action cancelled", + "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", + "ONNX model": "ONNX model", + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 48688b6a..26f9bd47 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -324,7 +324,7 @@ mod tests { h.step(); let w = widgets(&mut h); assert!(w.contains("\"denoise:setup\""), "a raw photo with no model is offered the setup: {w}"); - // one click lands in Settings ▸ AI Denoise, where the model LightCraft knows is listed + // One click lands in Settings, with local installation available even without a download offer. let r = h.request("ui.clickWidget", json!({"id": "denoise:setup"}), t); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -332,7 +332,8 @@ mod tests { assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); h.settle(SETTLE); let listed = h.app.session.execute("denoise.models.list", &json!({})).unwrap(); - assert!(listed["models"].as_array().unwrap().iter().any(|m| m["id"] == "rawnind-bayer" && m["installed"] == false), "{listed}"); + assert_eq!(listed["models"].as_array().unwrap().len(), lightcraft_denoise::known::all().len(), "{listed}"); + assert!(widgets(&mut h).contains("\"denoise:installFile\"")); h.app.ui.dialog = None; // a photo that is not raw: no offer h.request( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be2095fc..d0ccf2e9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -107,6 +107,8 @@ pub struct Services { pub picker: Option, /// Show an open dialog for photos; returns paths. pub pick_files: Option, + /// Local ONNX model; a manifest must accompany it. + pub pick_denoise_model: Option, /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`). pub pick_preset_files: Option, /// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…). diff --git a/crates/ui-egui/src/model_setup.rs b/crates/ui-egui/src/model_setup.rs index 238a4afe..e7094954 100644 --- a/crates/ui-egui/src/model_setup.rs +++ b/crates/ui-egui/src/model_setup.rs @@ -1,5 +1,6 @@ //! Resume a denoise request after model setup, bound to its original photo and library. +use crate::i18n::tr; use crate::{LightcraftApp, state::Dialog}; use lightcraft_catalog::{PhotoId, Source}; use lightcraft_engine::denoise::PhotoState; @@ -43,7 +44,7 @@ pub(crate) fn intercept(app: &mut LightcraftApp, command: &str, params: &Value) return Some(Err("modelSetup.cancel: use kind denoise".into())); } app.model_setup.denoise = None; - app.ui.status = "Pending photo action cancelled".into(); + app.ui.status = tr("Pending photo action cancelled").into(); return Some(Ok(json!({"cancelled": true}))); } if command == "denoise.models.downloadCancel" { @@ -85,7 +86,7 @@ pub(crate) fn intercept(app: &mut LightcraftApp, command: &str, params: &Value) app.model_setup.denoise = Some(request); app.model_setup.next_check = 0.0; app.ui.dialog = Some(Dialog::Settings { tab: "denoise".into() }); - app.ui.status = "Install a denoise model to enable AI Denoise on this photo".into(); + app.ui.status = tr("Install a denoise model to enable AI Denoise on this photo").into(); json!({"setupRequired": true, "photo": id.0}) })) } @@ -94,8 +95,8 @@ pub(crate) fn notice(app: &mut LightcraftApp, ui: &mut egui::Ui, kind: &str) { if kind != "denoise" || !app.model_setup.denoise.as_ref().is_some_and(|r| r.valid(app)) { return; } - ui.add(egui::Label::new("After installation, AI Denoise will turn on for the requesting photo.").wrap()); - let r = ui.small_button("Cancel pending action"); + ui.add(egui::Label::new(tr("After installation, AI Denoise will turn on for the requesting photo.")).wrap()); + let r = ui.small_button(tr("Cancel pending action")); crate::widgets::register(ui.ctx(), "modelSetup:cancel:denoise", r.rect); if r.clicked() { let _ = app.run("modelSetup.cancel", json!({"kind": "denoise"})); @@ -118,7 +119,7 @@ pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { app.model_setup.next_check = now + 0.25; let Some(request) = app.model_setup.denoise.take() else { return }; if !request.valid(app) { - app.ui.status = "Pending photo action cancelled because its library or photo changed".into(); + app.ui.status = tr("Pending photo action cancelled because its library or photo changed").into(); return; } if matches!(app.session.denoise_photo_state(request.photo), PhotoState::NoModel | PhotoState::NotApplicable) { @@ -127,7 +128,7 @@ pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { return; } match app.run("denoise.toggle", json!({"id": request.photo.0, "enabled": true})) { - Ok(_) => app.ui.status = "AI Denoise is enabled on the requesting photo".into(), + Ok(_) => app.ui.status = tr("AI Denoise is enabled on the requesting photo").into(), Err(error) => app.ui.status = error, } } diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index bd69b0cf..bf89acd2 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -5,6 +5,7 @@ //! slider mixes in. It is cached data made in the background for the photos being looked at, and never a file in the //! library. A model is fetched only when the user presses Download and accepts its terms. +use crate::i18n::{tr, tr_format}; use egui::RichText; use lightcraft_catalog::PhotoId; use lightcraft_engine::denoise::PhotoState; @@ -110,7 +111,7 @@ fn watch_downloads(app: &mut LightcraftApp, ctx: &egui::Context) { let (mut keep, mut installed) = (Vec::new(), Vec::new()); for id in std::mem::take(&mut app.caches.denoise.dl_watch) { match rows.iter().find(|r| r["id"] == id.as_str()).and_then(|r| r["state"].as_str()) { - Some("running" | "done") => keep.push(id), + Some("running" | "done" | "installing") => keep.push(id), Some("installed") => installed.push(id), _ => {} } @@ -119,7 +120,7 @@ fn watch_downloads(app: &mut LightcraftApp, ctx: &egui::Context) { for id in installed { let _ = app.session.execute("denoise.models.downloadCancel", &json!({"id": id})); app.caches.denoise.epoch += 1; - let text = "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works"; + let text = tr("The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works"); app.ui.status = text.into(); app.toast(ctx, text); } @@ -150,7 +151,7 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am PhotoState::NotApplicable | PhotoState::Ready => {} PhotoState::NoModel => { egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { - note(ui, "AI Denoise needs a model: a one-time download, in Settings.", t.text_dim); + note(ui, "AI Denoise needs a model. Install one in Settings.", t.text_dim); ui.add_space(2.0); let r = ui.small_button(crate::i18n::tr("Set up AI Denoise…")); register(ui.ctx(), "denoise:setup", r.rect); @@ -180,15 +181,22 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am } PhotoState::Queued { ahead } => { egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { - let text = if ahead == 0 { "Next up for AI Denoise…".to_string() } else { format!("In line for AI Denoise ({ahead} ahead)…") }; + let text = if ahead == 0 { + tr("Next up for AI Denoise…").to_string() + } else { + tr_format!("In line for AI Denoise ({ahead} ahead)…", ahead = ahead) + }; note(ui, &text, t.text_dim); }); } PhotoState::Running { done, total } => { egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; - let text = - if total > 0 { format!("Making the denoised picture… {}%", (frac * 100.0).round() as u32) } else { "Starting…".to_string() }; + let text = if total > 0 { + tr_format!("Making the denoised picture… {}%", (frac * 100.0).round() as u32) + } else { + tr("Starting…").to_string() + }; let bar = ui.add(egui::ProgressBar::new(frac).desired_width(ui.available_width().min(240.0)).text(RichText::new(text).size(11.0))); register(ui.ctx(), "denoise:progress", bar.rect); }); @@ -235,6 +243,24 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { .map(|v| v["downloads"].as_array().cloned().unwrap_or_default()) .unwrap_or_default(); heading(ui, t, "Models"); + let r = ui.add_enabled(runtime && app.services.pick_denoise_model.is_some(), egui::Button::new(tr("Install from file…"))); + register(ui.ctx(), "denoise:installFile", r.rect); + if r.clicked() + && let Some(path) = app.services.pick_denoise_model.as_mut().and_then(|f| f().into_iter().next()) + && let Ok(info) = app.run("denoise.models.inspect", json!({"path": path})) + { + app.ui.dialog = Some(crate::state::Dialog::DenoiseModel { info, accepted: false }); + } + if all.is_empty() { + hint(ui, t, "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads."); + } + if let Some(job) = downloads.iter().find(|d| d["id"] == "local-install") { + match job["state"].as_str() { + Some("installing") => hint(ui, t, "Checking and installing the model…"), + Some("failed") => hint(ui, t, job["error"].as_str().unwrap_or("Model installation failed")), + _ => {} + } + } for m in &all { let dl = downloads.iter().find(|d| d["id"] == m["id"]); model_row(app, ui, t, m, dl, runtime); @@ -290,8 +316,13 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V .as_u64() .and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())) .unwrap_or_default(); - let text = - format!("Denoising {name}: {done} of {total} tiles{}", if queued > 0 { format!(" · {queued} waiting") } else { String::new() }); + let text = tr_format!( + "Denoising {name}: {done} of {total} tiles{}", + if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() }, + name = name, + done = done, + total = total + ); let bar = ui.add(egui::ProgressBar::new(frac).desired_width(380.0).text(RichText::new(text).size(11.5))); register(ui.ctx(), "denoise:settingsProgress", bar.rect); } @@ -302,13 +333,13 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } } ui.horizontal(|ui| { - let r = ui.button("Denoise all photos with an Amount"); + let r = ui.button(tr("Denoise all photos with an Amount")); register(ui.ctx(), "denoise:queueAll", r.rect); if r.clicked() { let _ = app.run("denoise.queue", json!({"scope": "withAmount"})); } if running.is_some() || queued > 0 { - let r = ui.button("Stop"); + let r = ui.button(tr("Stop")); register(ui.ctx(), "denoise:stop", r.rect); if r.clicked() { let _ = app.run("denoise.cancel", json!({})); @@ -320,7 +351,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V hint( ui, t, - &format!( + &tr_format!( "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", count(files, "cached picture", "cached pictures"), mb(Some(bytes)) @@ -330,16 +361,16 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V ui.label(RichText::new(crate::i18n::tr("Limit")).color(t.text_label)); let current = list["cacheGb"].as_u64().unwrap_or(20) as u32; let mut choice = current; - egui::ComboBox::from_id_salt("denoise-cache-limit").selected_text(format!("{current} GB")).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| { for gb in CACHE_LIMITS { - ui.selectable_value(&mut choice, gb, format!("{gb} GB")); + ui.selectable_value(&mut choice, gb, tr_format!("{size} GB", size = gb)); } }); if choice != current { let _ = app.run("denoise.settings", json!({"cacheGb": choice})); app.caches.denoise.epoch += 1; } - let r = ui.button("Clear cache"); + let r = ui.button(tr("Clear cache")); register(ui.ctx(), "denoise:clear", r.rect); if r.clicked() { let _ = app.run("denoise.clear", json!({})); @@ -351,12 +382,12 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V /// computer's, and only the comparison matters. fn device_line(d: &Value, run_on: &str) -> String { match d["kind"].as_str() { - _ if run_on == "cpu" => "The processor does the work.".into(), - Some("gpu") => format!("Running on {}.", d["adapter"].as_str().unwrap_or("the graphics card")), + _ if run_on == "cpu" => tr("The processor does the work.").into(), + Some("gpu") => tr_format!("Running on {}.", d["adapter"].as_str().unwrap_or(tr("the graphics card"))), Some("cpu") => { - format!("Running on the processor: {}.", d["reason"].as_str().unwrap_or("the graphics card is not used").trim_end_matches('.')) + tr_format!("Running on the processor: {}.", tr(d["reason"].as_str().unwrap_or("the graphics card is not used")).trim_end_matches('.')) } - _ => "The graphics card is set up when the next photo is made.".into(), + _ => tr("The graphics card is set up when the next photo is made.").into(), } } @@ -375,9 +406,9 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, ui.label(RichText::new(size).color(t.text_dim)); } if selected { - ui.label(RichText::new("in use").color(t.accent)); + ui.label(RichText::new(tr("in use")).color(t.accent)); } else if installed { - ui.label(RichText::new("installed").color(t.text_dim)); + ui.label(RichText::new(tr("installed")).color(t.text_dim)); } }); ui.label(RichText::new(licence_line(m)).font(t.font(11.5)).color(if m["licence"]["commercial"] == "yes" { @@ -387,10 +418,10 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, })); match m["accepted"]["selfTest"].as_object() { Some(test) if test.get("ok").and_then(Value::as_bool) == Some(true) => { - ui.label(RichText::new("Tested and works on this computer").font(t.font(11.5)).color(t.text_dim)); + ui.label(RichText::new(tr("Tested and works on this computer")).font(t.font(11.5)).color(t.text_dim)); } Some(_) => { - ui.label(RichText::new("Failed its last test").font(t.font(11.5)).color(t.caution)); + ui.label(RichText::new(tr("Failed its last test")).font(t.font(11.5)).color(t.caution)); } None => {} } @@ -399,15 +430,15 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0)); let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let text = if total > 0 && got >= total { - "Checking…".to_string() + tr("Checking…").to_string() } else { - format!("{} of {} from {host}", mb(Some(got)), mb(Some(total))) + tr_format!("{} of {} from {host}", mb(Some(got)), mb(Some(total)), host = host) }; let bar = ui.add(egui::ProgressBar::new(frac).desired_width(260.0).text(RichText::new(text).font(t.font(11.5)))); register(ui.ctx(), format!("denoise:progress:{id}"), bar.rect); } - (Some("done"), _) => { - ui.label(RichText::new("Downloaded and checked. Installing…").font(t.font(11.5)).color(t.text_dim)); + (Some("done" | "installing"), _) => { + ui.label(RichText::new(tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); } (Some("failed"), Some(d)) => { let why = sentence(d["error"].as_str().unwrap_or("The download failed")); @@ -418,21 +449,21 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if installed { - let r = ui.button("Remove"); + let r = ui.button(tr("Remove")); register(ui.ctx(), format!("denoise:remove:{id}"), r.rect); if r.clicked() { let _ = app.run("denoise.models.remove", json!({"id": id})); app.caches.denoise.epoch += 1; } if selected { - let r = ui.button("Turn off"); + let r = ui.button(tr("Turn off")); register(ui.ctx(), format!("denoise:off:{id}"), r.rect); if r.clicked() { let _ = app.run("denoise.models.select", json!({"id": null})); app.caches.denoise.epoch += 1; } } else { - let r = ui.button("Use"); + let r = ui.button(tr("Use")); register(ui.ctx(), format!("denoise:use:{id}"), r.rect); if r.clicked() { let _ = app.run("denoise.models.select", json!({"id": id})); @@ -442,7 +473,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, } else { match dl_state { Some("running") => { - let r = ui.button("Cancel"); + let r = ui.button(tr("Cancel")); register(ui.ctx(), format!("denoise:cancelDownload:{id}"), r.rect); if r.clicked() { let _ = app.run("denoise.models.downloadCancel", json!({"id": id})); @@ -450,12 +481,12 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, } } // the engine installs it within a moment; nothing to press - Some("done") => {} + Some("done" | "installing") => {} _ => { if let Some(url) = m["source"].as_str() { let r = ui - .button("Open page") - .on_hover_text("Opens the model's own page in your browser, to read about it or get the file yourself."); + .button(tr("Open page")) + .on_hover_text(tr("Opens the model's own page in your browser, to read about it or get the file yourself.")); register(ui.ctx(), format!("denoise:get:{id}"), r.rect); if r.clicked() { open_page(app, url); @@ -463,11 +494,12 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, } if !host.is_empty() { let label = if dl_state == Some("failed") { "Try again" } else { "Download" }; - let tip = format!( + let tip = tr_format!( "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - mb(m["sizeBytes"].as_u64()) + mb(m["sizeBytes"].as_u64()), + host = host ); - let r = ui.button(label).on_hover_text(tip); + let r = ui.button(tr(label)).on_hover_text(tip); register(ui.ctx(), format!("denoise:download:{id}"), r.rect); if r.clicked() { open_download_dialog(app, m); @@ -502,14 +534,17 @@ fn open_download_dialog(app: &mut LightcraftApp, m: &Value) { /// The dialog's OK for a denoise model: start the download and watch it. pub fn install(app: &mut LightcraftApp, info: &Value, accepted: bool) -> Result { if !accepted { - return Err("Tick the box to accept the model's terms first".into()); + return Err(tr("Tick the box to accept the model's terms first").into()); } app.caches.denoise.epoch += 1; - let id = info["download"].as_str().ok_or("There is no download for this model")?; - let r = app.run("denoise.models.download", json!({"id": id, "acknowledged": true})); + let (id, r) = if let Some(id) = info["download"].as_str() { + (id.to_string(), app.run("denoise.models.download", json!({"id": id, "acknowledged": true}))) + } else { + let path = info["path"].as_str().ok_or_else(|| tr("There is no model file to install").to_string())?; + ("local-install".into(), app.run("denoise.models.install", json!({"path": path, "acknowledged": true, "background": true}))) + }; if r.is_ok() { - app.caches.denoise.dl_watch.push(id.to_string()); - // the progress is in Settings: stay there + app.caches.denoise.dl_watch.push(id); app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "denoise".into() }); } r @@ -517,7 +552,7 @@ pub fn install(app: &mut LightcraftApp, info: &Value, accepted: bool) -> Result< /// "1 photo" / "3 photos": the count with the right noun. fn count(n: u64, one: &str, many: &str) -> String { - format!("{n} {}", if n == 1 { one } else { many }) + tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })) } pub(super) fn window_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64, watching: bool) -> &'static str { @@ -544,9 +579,9 @@ fn pace_for(focused: bool, minimized: bool, worked: f64, moved: f64, watching: b pub(super) fn mb(bytes: Option) -> String { match bytes { - Some(b) if b >= 10_000_000 => format!("{} MB", (b as f64 / 1e6).round() as u64), - Some(b) if b >= 1_000_000 => format!("{:.1} MB", b as f64 / 1e6), - Some(b) => format!("{} KB", (b as f64 / 1e3).round().max(1.0) as u64), + Some(b) if b >= 10_000_000 => tr_format!("{} MB", (b as f64 / 1e6).round() as u64), + Some(b) if b >= 1_000_000 => tr_format!("{:.1} MB", b as f64 / 1e6), + Some(b) => tr_format!("{} KB", (b as f64 / 1e3).round().max(1.0) as u64), None => String::new(), } } @@ -565,11 +600,12 @@ pub(super) fn sentence(s: &str) -> String { pub(super) fn licence_line(m: &Value) -> String { let name = m["licence"]["name"].as_str().filter(|s| !s.is_empty()).unwrap_or("Unknown licence"); let terms = match m["licence"]["commercial"].as_str() { + Some("yes") if name.starts_with("GPL") => "commercial use subject to GPL conditions", Some("yes") => "commercial use allowed", Some("no") => "non-commercial use only", _ => "terms unclear", }; - format!("{name} · {terms}") + tr_format!("{name} · {terms}", name = tr(name), terms = tr(terms)) } pub(super) fn open_page(app: &mut LightcraftApp, url: &str) { @@ -582,14 +618,15 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info let kind = info["kind"].as_str().unwrap_or("unsupported"); let file = info["fileName"].as_str().unwrap_or(""); if kind == "unsupported" { - ui.label(RichText::new("This file cannot be used yet").font(t.semibold(13.5)).color(t.caution)); + ui.label(RichText::new(tr("This file cannot be used yet")).font(t.semibold(13.5)).color(t.caution)); ui.add( egui::Label::new( - RichText::new(sentence(info["reason"].as_str().unwrap_or("It is not a denoise model LightCraft understands"))).color(t.text_label), + RichText::new(sentence(tr(info["reason"].as_str().unwrap_or("It is not a denoise model LightCraft understands")))) + .color(t.text_label), ) .wrap(), ); - ui.label(RichText::new(format!("{file} · {}", mb(info["sizeBytes"].as_u64()))).color(t.text_dim)); + ui.label(RichText::new(tr_format!("{file} · {size}", file = file, size = mb(info["sizeBytes"].as_u64()))).color(t.text_dim)); return; } let m = &info["model"]; @@ -598,7 +635,7 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info ui.label(RichText::new(mb(info["sizeBytes"].as_u64())).color(t.text_dim)); }); if info["alreadyInstalled"] == true { - ui.label(RichText::new("Already installed. Installing again is harmless.").color(t.text_dim)); + ui.label(RichText::new(tr("Already installed. Remove it before installing a replacement.")).color(t.text_dim)); } let download = info["download"].is_string(); if download { @@ -606,8 +643,12 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info let after = if info["domain"] == "denoise" { "starts using it for AI Denoise" } else { "makes AI Denoise available" }; ui.add( egui::Label::new( - RichText::new(format!("Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.")) - .color(t.text_label), + RichText::new(tr_format!( + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + host = host, + after = tr(after) + )) + .color(t.text_label), ) .wrap(), ); @@ -620,17 +661,17 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info } let provenance = m["provenance"].as_str().unwrap_or(""); if !provenance.is_empty() { - ui.add(egui::Label::new(RichText::new(format!("Trained on: {provenance}")).color(t.text_dim)).wrap()); + ui.add(egui::Label::new(RichText::new(tr_format!("Trained on: {provenance}", provenance = provenance)).color(t.text_dim)).wrap()); } if kind == "draft" { ui.add_space(2.0); - ui.label(RichText::new("LightCraft does not know this model, so it assumed:").color(t.text_label)); + ui.label(RichText::new(tr("LightCraft does not know this model, so it assumed:")).color(t.text_label)); for a in info["assumptions"].as_array().into_iter().flatten().filter_map(Value::as_str) { - ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(t.font(12.0)).color(t.text_dim)).wrap()); + ui.add(egui::Label::new(RichText::new(tr_format!("• {assumption}", assumption = tr(a))).font(t.font(12.0)).color(t.text_dim)).wrap()); } } if let Some(url) = m["source"].as_str().or(m["licence"]["url"].as_str()) { - let r = ui.link("Open the model's page"); + let r = ui.link(tr("Open the model's page")); register(ui.ctx(), "denoiseModel:page", r.rect); if r.clicked() { open_page(app, url); @@ -643,5 +684,5 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info } else { "The model is kept on this computer only. LightCraft never uploads or shares it." }; - ui.label(RichText::new(closing).font(t.font(11.5)).color(t.text_dim)); + ui.label(RichText::new(tr(closing)).font(t.font(11.5)).color(t.text_dim)); } diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 51a54bc9..fb8d121e 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -280,7 +280,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { super::denoise::detail_toggle(app, ui, id, on, applicable); control(app, ui, d, "enhance.denoise", applicable && on); super::denoise::detail_status(app, ui, id, if on { d.enhance.denoise } else { 0.0 }); - sub_title(ui, crate::i18n::tr("Manual Noise Reduction")); + sub_title(ui, crate::i18n::tr("Noise Reduction")); for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast", "detail.nrColor", "detail.nrColorDetail", "detail.nrColorSmoothness"] { control(app, ui, d, c, true); diff --git a/crates/ui-egui/src/tests_model_setup.rs b/crates/ui-egui/src/tests_model_setup.rs index 892df22d..85a7ca7f 100644 --- a/crates/ui-egui/src/tests_model_setup.rs +++ b/crates/ui-egui/src/tests_model_setup.rs @@ -30,7 +30,7 @@ fn setup(name: &str) -> (LightcraftApp, PathBuf, PhotoId) { (LightcraftApp::new(s, Services::default()), dir, id) } -fn install_denoise(app: &mut LightcraftApp, dir: &Path) { +fn denoise_file(dir: &Path) -> PathBuf { let source = dir.join("source"); std::fs::create_dir_all(&source).unwrap(); let bytes = lightcraft_denoise::synthetic::smoothing_onnx(); @@ -50,7 +50,38 @@ fn install_denoise(app: &mut LightcraftApp, dir: &Path) { gain: Gain::None, }; std::fs::write(source.join("denoise-model.json"), serde_json::to_vec(&m).unwrap()).unwrap(); - app.run("denoise.models.install", json!({"path": source.join("model.onnx").to_string_lossy(), "acknowledged": true})).unwrap(); + source.join("model.onnx") +} + +fn install_denoise(app: &mut LightcraftApp, dir: &Path) { + app.run("denoise.models.install", json!({"path": denoise_file(dir), "acknowledged": true})).unwrap(); +} + +#[test] +fn accepting_a_local_model_installs_in_background_and_resumes_the_pending_action() { + let (mut app, dir, raw) = setup("local-install"); + let path = denoise_file(&dir); + app.run("denoise.toggle", json!({"id": raw.0, "enabled": true})).unwrap(); + let info = app.run("denoise.models.inspect", json!({"path": path})).unwrap(); + assert!(crate::panels::denoise::install(&mut app, &info, false).is_err()); + assert!(!dir.join("denoise/test-smoothing/model.onnx").exists()); + let result = crate::panels::denoise::install(&mut app, &info, true).unwrap(); + assert_eq!(result["started"], "local-install"); + assert_eq!(app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); + let ctx = egui::Context::default(); + for _ in 0..600 { + crate::panels::denoise::pump(&mut app, &ctx); + if app.session.denoise_photo_state(raw) != lightcraft_engine::denoise::PhotoState::NoModel { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + resume(&mut app); + assert_eq!(amount(&app, raw), 50.0); + assert!(dir.join("denoise/test-smoothing/model.onnx").is_file()); + assert!(path.is_file(), "a user-supplied original is preserved"); + drop(app); + let _ = std::fs::remove_dir_all(dir); } fn amount(app: &LightcraftApp, id: PhotoId) -> f64 { diff --git a/docs/denoise.md b/docs/denoise.md index b8fd2901..f1196e9b 100644 --- a/docs/denoise.md +++ b/docs/denoise.md @@ -15,22 +15,29 @@ An active slider gesture finishes before the pending action resumes. ## Models and terms **No model weights are bundled.** Models live in the per-user `denoise-models` folder -(override with `LIGHTCRAFT_DENOISE_MODELS`). Settings offers a pinned, verified download -of RawNIND UtNet2 for Bayer sensors, or installation of a user-supplied ONNX file with -a `denoise-model.json` beside it. Terms must be acknowledged before installation or +(override with `LIGHTCRAFT_DENOISE_MODELS`). Settings provides **Install from file…** +for a user-supplied ONNX file with a `denoise-model.json` beside it. The default build +offers no model downloads. The separate `rawnind-model` Cargo feature enables the +pinned RawNIND offer only when a release's model policy permits it. +Terms must be acknowledged before installation or download. Downloads use the shared pure-Rust `lightcraft-fetch` client, with resume, progress, cancellation, size limits and SHA-256 verification. -RawNIND's offered ONNX weights are **GPL-3.0**, as recorded in the pinned model catalog. +RawNIND's optional ONNX weights are **GPL-3.0**, as recorded in the pinned model catalog. They are downloaded separately after consent and read by our own Rust executor. Whether releases should offer this download remains a maintainer policy decision; permissively licensed or independently trained weights remain a gap. This is not a claim that optional downloading settles licensing questions. No GPL decoder or neural -runtime code is included. Model metadata is in `crates/denoise/src/known.rs`. +runtime code is included. Model metadata is in `crates/denoise-core/src/known.rs`. Installation checks the manifest, model hash and size, then runs a synthetic self-test: finite output of the promised shape, repeatability, the declared scale and reduced noise. Unsupported operators, malformed files and a failed self-test return errors. +The UI performs copying, extraction, hashing, parsing and CPU/GPU self-tests on one +background installation worker. `denoise.models.downloads` and `denoise.pump` report +`installing`, `installed` or `failed`; an existing model is preserved on a failed replacement. +Command clients can request the same asynchronous path with `background: true`. +The synchronous command remains available for scripts that require the result immediately. The current contract is `bayerToRgb`: normalized, black-subtracted, non-white-balanced RGGB planes `[1, 4, tile, tile]` produce camera RGB `[1, 3, 2*tile, 2*tile]`. @@ -63,10 +70,16 @@ reduction and report their limitation. ## Pure-Rust CPU and GPU execution The CPU executor reads a bounded subset of ONNX into checked network data. Convolutions -use bounded im2col blocks and `faer-core 0.17.1` with its Rust-intrinsic GEMM kernels, -without BLAS, generated assembly or an inner thread pool. Tiles share the outer worker +use bounded im2col blocks and `faer-core 0.17.1` with its pure-Rust GEMM backend, +without BLAS, C dependencies or an inner thread pool. That backend includes +architecture-specific assembly in its dependencies. Tiles share the outer worker pool. A scalar implementation checks the optimized operators on synthetic networks. Tract is not a workspace dependency or a product feature. +CPU inference and the GPU model module are feature-gated; ordinary engine/web builds +do not compile the faer/GEMM backend. The engine depends on the inference crate only through `denoise = ["dep:lightcraft-denoise", …]`; +lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 100 megapixels; at most +four tile calls run together, chosen within the process-wide working-memory budget. +One idle CPU workspace is retained, and cache costs reserve room for the blended picture. The GPU executor uses our WGSL kernels through wgpu. It checks a real model's output against the CPU and falls back on errors, unsupported networks or a slower device. @@ -97,8 +110,8 @@ Fresh checks can be run using an already installed model and CC0 raw samples: ```text LC_DENOISE_MODEL= cargo test --release -p lightcraft-gpu --lib real_model_on_the_gpu_matches_cpu -- --ignored --nocapture -LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -p lightcraft-engine --features denoise --test denoise_cpu -- --ignored --nocapture -LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -p lightcraft-engine --features denoise --test denoise_real -- --ignored --nocapture +LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_cpu -- --ignored --nocapture +LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_real -- --ignored --nocapture ``` ## Commands and limits diff --git a/docs/parity.md b/docs/parity.md index a4718949..eaf9ff8e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -26,7 +26,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | +| F. Edit panel — global adjustments (EDIT) | 42 | 2 | 4 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | @@ -79,7 +79,7 @@ Take the first one nobody is working on. Object and Describe masks run SAM 3 in pure Rust (`crates/segment`); the weights (SAM License) are never bundled: the app offers a consented, verified download, but **LightCraft's CDN mirrors are not configured yet** (the built-in list is empty, so today users need their own mirror or a manual install). Subject / Sky / People could - use the same model with fixed prompts. Bayer RAW denoise now runs on pure-Rust CPU/GPU with separately installed weights; its opt-in GPL model policy, linear RGB and quality comparison remain open. Super-resolution models remain a maintainer decision. + use the same model with fixed prompts; denoise / super-resolution models remain a maintainer decision. 7. **HDR** (Q. HDR, LR-EXP-HDR), **video** (R. Video), **Classic output modules** (Map view, Book, Slideshow module, Print, publish): large, well understood, lower priority than 1–5. 8. **LR-VIEW-ZOOM** (P0): pinch zoom and two-finger pan now work, with stable image cursors between gesture events. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index e330a8e1..376d9e3c 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -39,6 +39,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("tiff", Class::Layer(0)), ("sysmem", Class::Layer(0)), ("fetch", Class::Layer(0)), + ("denoise-core", Class::Layer(1)), ("denoise", Class::Layer(1)), ("raw", Class::Layer(1)), ("codecs", Class::Layer(1)), @@ -69,6 +70,7 @@ pub const TABLE: &[(&str, Class)] = &[ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[ &["geom", "color", "raster"], &["tiff", "raster"], + &["denoise-core", "denoise"], &["meta", "raw"], &["meta", "codecs"], &["meta", "develop"],