LR-EDIT-DETAIL-DENOISE: address installation and model-policy review

Generated with Codex.
This commit is contained in:
Daniel Voshart 2026-10-08 18:47:57 -04:00
parent 7935999d94
commit fd4019300a
59 changed files with 1437 additions and 217 deletions

17
Cargo.lock generated
View File

@ -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",

View File

@ -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" }

9
NOTICE
View File

@ -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

View File

@ -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.

View File

@ -205,6 +205,7 @@ fn services(originals: Originals, backend: Option<Backend>, 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,

View File

@ -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"] }

View File

@ -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"))

View File

@ -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 }

View File

@ -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`.

View File

@ -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 {

View File

@ -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<Known> {
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 }
);
}
}

View File

@ -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};

View File

@ -52,6 +52,13 @@ pub struct Header {
pub key: String,
}
fn allocate<T: Clone>(n: usize, value: T) -> Result<Vec<T>, 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<u8> {
fn encode_strip(img: &Rgb32f, y0: usize, y1: usize) -> Result<Vec<u8>, 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<u8> {
}
}
}
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<Vec<u8>> = strips.par_iter().map(|&(a, b)| encode_strip(img, a, b)).collect();
let packed: Vec<Vec<u8>> = strips.par_iter().map(|&(a, b)| encode_strip(img, a, b)).collect::<Result<_, _>>()?;
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<Start, ProductError> {
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<Window>, 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<Window>, 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<usize> = (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<Window>, 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()))
}

View File

@ -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<T: Clone>(n: usize, value: T) -> Result<Vec<T>, 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)| {

View File

@ -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 }

View File

@ -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`.

View File

@ -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<T> = std::result::Result<T, NetError>;
fn bad(why: &str) -> NetError {

View File

@ -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;

View File

@ -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());

View File

@ -2,7 +2,7 @@
//!
//! ```text
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_MOSAICS=<folder> LC_DENOISE_OUT=<folder> \
//! 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 `<name>.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;

View File

@ -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 }

View File

@ -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<Source> {
/// 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<Value> {
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<dyn crate::denoise::Model>)> {
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<std::result::Result<(Value, Arc<dyn crate::denoise::Model>), String>>,
}
fn activate_install(s: &mut Session, result: &Value, model: Arc<dyn crate::denoise::Model>, activate: bool) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
@ -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<Value> {
pub fn specs() -> Vec<CommandSpec> {
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),

View File

@ -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<Value, String>;
/// 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<T: Send + 'static, R>(
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<PathBuf>,
/// Models being downloaded at the user's request.
pub downloads: Downloads,
pub(crate) install: Option<crate::cmd::denoise::InstallJob>,
/// How a model is loaded (tests put another in).
pub loader: Loader,
pub(crate) active: Option<Active>,
@ -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(())

View File

@ -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<String> = 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));

View File

@ -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;

View File

@ -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::<SourceInfo>() + 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| {

View File

@ -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);

View File

@ -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<u8> {
let data: Vec<u16> = (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();

View File

@ -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 }

View File

@ -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<String> {
if env_disabled() {
return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into());

View File

@ -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 }

View File

@ -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}"
}

View File

@ -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"
}

View File

@ -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}"
}

View File

@ -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"
}

View File

@ -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}"
}

View File

@ -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"
}

View File

@ -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}"
}

View File

@ -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"
}

View File

@ -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}"
}

View File

@ -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"
}

View File

@ -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}"
}

View File

@ -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"
}

View File

@ -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}"
}

View File

@ -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"
}

View File

@ -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(

View File

@ -107,6 +107,8 @@ pub struct Services {
pub picker: Option<pick::Picker>,
/// Show an open dialog for photos; returns paths.
pub pick_files: Option<PickFiles>,
/// Local ONNX model; a manifest must accompany it.
pub pick_denoise_model: Option<PickFiles>,
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
pub pick_preset_files: Option<PickFiles>,
/// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…).

View File

@ -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,
}
}

View File

@ -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<Value, String> {
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<u64>) -> 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));
}

View File

@ -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);

View File

@ -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 {

View File

@ -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=<model.onnx> cargo test --release -p lightcraft-gpu --lib real_model_on_the_gpu_matches_cpu -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features denoise --test denoise_cpu -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features denoise --test denoise_real -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_cpu -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_real -- --ignored --nocapture
```
## Commands and limits

View File

@ -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.

View File

@ -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"],