mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 02:30:15 -06:00
Catalog format v9 adds Photo.preview_checked and Op::SetContent.preview_checked: the app version (CARGO_PKG_VERSION) whose decoder set preview_only, empty when it isn't set. Import and content_op (Reload, Relink) stamp it; undo swaps it back. Catalogs from before v9 read an empty stamp, so their preview-only photos count as stale (Photo::preview_only_stale) and will be rechecked once. The version guard test now also accepts a format version that only adds fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
862 lines
42 KiB
Rust
862 lines
42 KiB
Rust
//! AI denoise through the engine: installing a model, making a photo's denoised picture in the background and for an
|
||
//! export, the Amount slider mixing it in, and the cache. A tiny stand-in model (it halves every sample) takes the
|
||
//! place of a real one, which is enough to see what the machinery does with its answer; how well real models denoise is
|
||
//! measured separately (`crates/denoise/tests/real_model.rs`).
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
|
||
use lightcraft_denoise::manifest::{DenoiserManifest, Domain, Gain};
|
||
use lightcraft_denoise::run::TileRunner;
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::Session;
|
||
use crate::denoise::{Loader, Model, PhotoState, RunOn};
|
||
use crate::export::{ExportOptions, export_photo};
|
||
use crate::tests_xmp::{synthetic_dng_of, temp_dir};
|
||
|
||
/// A model that makes every output pixel half of its cell's colour: a picture that is plainly not the plain one.
|
||
struct Dim {
|
||
runs: Arc<AtomicUsize>,
|
||
}
|
||
|
||
impl TileRunner for Dim {
|
||
fn run(&self, input: &[f32]) -> Result<Vec<f32>, lightcraft_denoise::Error> {
|
||
self.runs.fetch_add(1, Ordering::Relaxed);
|
||
let t = ((input.len() / 4) as f64).sqrt() as usize;
|
||
let side = 2 * t;
|
||
let mut out = vec![0f32; 3 * side * side];
|
||
for y in 0..side {
|
||
for x in 0..side {
|
||
let i = (y / 2) * t + x / 2;
|
||
let (r, g, b) = (input[i], (input[t * t + i] + input[2 * t * t + i]) / 2.0, input[3 * t * t + i]);
|
||
out[y * side + x] = r * 0.5;
|
||
out[side * side + y * side + x] = g * 0.5;
|
||
out[2 * side * side + y * side + x] = b * 0.5;
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
}
|
||
|
||
impl Model for Dim {
|
||
fn runner(&self, _: RunOn, threads: usize) -> (&dyn TileRunner, usize) {
|
||
(self, threads)
|
||
}
|
||
|
||
fn self_test(&self, _: RunOn) -> Result<Value, String> {
|
||
Ok(json!({"ok": true}))
|
||
}
|
||
}
|
||
|
||
fn dim_loader(runs: &Arc<AtomicUsize>) -> Loader {
|
||
let runs = runs.clone();
|
||
Arc::new(move |_, _| Ok(Arc::new(Dim { runs: runs.clone() }) as Arc<dyn Model>))
|
||
}
|
||
|
||
fn manifest(id: &str) -> DenoiserManifest {
|
||
DenoiserManifest {
|
||
id: id.into(),
|
||
name: "Stand-in".into(),
|
||
version: "1".into(),
|
||
licence: Default::default(),
|
||
source: None,
|
||
sha256: None,
|
||
size_bytes: None,
|
||
provenance: "made up for a test".into(),
|
||
domain: Domain::BayerToRgb,
|
||
tile: 64,
|
||
overlap: 16,
|
||
gain: Gain::None,
|
||
}
|
||
}
|
||
|
||
/// The model's `.onnx` (not a real one: the stand-in loader never reads it) with its manifest beside it.
|
||
fn model_files(dir: &Path, id: &str) -> PathBuf {
|
||
let src = dir.join(format!("src-{id}"));
|
||
std::fs::create_dir_all(&src).unwrap();
|
||
std::fs::write(src.join("denoise-model.json"), serde_json::to_vec(&manifest(id)).unwrap()).unwrap();
|
||
std::fs::write(src.join("model.onnx"), format!("not an onnx file, only a stand-in for {id}")).unwrap();
|
||
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();
|
||
synthetic_dng_of(w, h, cfa, data, None, Default::default())
|
||
}
|
||
|
||
struct Setup {
|
||
dir: PathBuf,
|
||
s: Session,
|
||
runs: Arc<AtomicUsize>,
|
||
}
|
||
|
||
/// A session with a library on disk holding one raw photo, and the stand-in model installed and chosen.
|
||
fn setup(tag: &str, with_model: bool) -> Setup {
|
||
let dir = temp_dir(tag);
|
||
let originals = dir.join("originals");
|
||
std::fs::create_dir_all(&originals).unwrap();
|
||
std::fs::write(originals.join("ramp.dng"), ramp_dng(192, 160, "RGGB")).unwrap();
|
||
let runs = Arc::new(AtomicUsize::new(0));
|
||
let mut s = Session::new().with_fs();
|
||
s.open_library(dir.join("library"), false).unwrap();
|
||
s.execute("library.import", &json!({"paths": [originals.join("ramp.dng").to_string_lossy()]})).unwrap();
|
||
let id = s.catalog.photos().next().unwrap().id;
|
||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||
s.set_denoise_models_dir(Some(dir.join("denoise-models")));
|
||
s.denoise.loader = dim_loader(&runs);
|
||
if with_model {
|
||
let onnx = model_files(&dir, "stand-in");
|
||
s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap();
|
||
}
|
||
Setup { dir, s, runs }
|
||
}
|
||
|
||
fn photo(s: &Session) -> lightcraft_catalog::PhotoId {
|
||
s.catalog.photos().next().unwrap().id
|
||
}
|
||
|
||
/// Pump until `n` photos are ready (the job runs on its own thread).
|
||
fn pump_until_ready(s: &mut Session, n: u64) -> Value {
|
||
for _ in 0..600 {
|
||
let r = s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
if r["ready"].as_u64() >= Some(n) {
|
||
return r;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||
}
|
||
panic!("not ready: {}", s.execute("denoise.status", &json!({})).unwrap());
|
||
}
|
||
|
||
fn mean(img: &lightcraft_raster::Rgba8) -> f64 {
|
||
let sum: u64 = img.data.iter().map(|p| u64::from(p[0]) + u64::from(p[1]) + u64::from(p[2])).sum();
|
||
sum as f64 / (img.data.len() * 3) as f64
|
||
}
|
||
|
||
fn rendered_mean(s: &mut Session, id: lightcraft_catalog::PhotoId) -> f64 {
|
||
mean(&s.render_now(id, 160, 160).unwrap().image)
|
||
}
|
||
|
||
fn set_amount(s: &mut Session, amount: f64) {
|
||
s.execute("develop.set", &json!({"control": "enhance.denoise", "value": amount})).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn a_model_is_installed_chosen_and_removed_with_the_terms_accepted() {
|
||
let mut x = setup("model", false);
|
||
let onnx = model_files(&x.dir, "stand-in");
|
||
// the terms come first
|
||
let e = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy()})).unwrap_err().to_string();
|
||
assert!(e.contains("licence has not been accepted"), "{e}");
|
||
let r = x.s.execute("denoise.models.list", &json!({})).unwrap();
|
||
assert_eq!(r["model"], Value::Null);
|
||
assert_eq!(r["runtime"], cfg!(feature = "denoise"));
|
||
// a file with no manifest is not guessed at
|
||
let stray = x.dir.join("stray.onnx");
|
||
std::fs::write(&stray, b"who knows").unwrap();
|
||
let e = x.s.execute("denoise.models.install", &json!({"path": stray.to_string_lossy(), "acknowledged": true})).unwrap_err().to_string();
|
||
assert!(e.contains("does not know this file"), "{e}");
|
||
// installed, it is the model in use
|
||
let r = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap();
|
||
assert_eq!((r["installed"]["id"].clone(), r["model"].clone()), (json!("stand-in"), json!("stand-in")), "{r}");
|
||
assert!(r["installed"]["accepted"]["sha256"].as_str().is_some_and(|h| h.len() == 64), "the file's hash is recorded: {r}");
|
||
assert!(x.s.denoise.active.is_some());
|
||
assert_eq!(x.s.execute("denoise.status", &json!({})).unwrap()["model"]["id"], "stand-in");
|
||
// it can be checked again, switched off and on, and removed
|
||
assert_eq!(x.s.execute("denoise.models.test", &json!({"id": "stand-in"})).unwrap()["ok"], true);
|
||
assert_eq!(x.s.execute("denoise.models.select", &json!({"id": null})).unwrap()["model"], Value::Null);
|
||
assert!(x.s.denoise.active.is_none());
|
||
assert!(x.s.execute("denoise.models.select", &json!({"id": "nothing"})).is_err());
|
||
x.s.execute("denoise.models.select", &json!({"id": "stand-in"})).unwrap();
|
||
assert!(x.s.denoise.active.is_some());
|
||
let r = x.s.execute("denoise.models.remove", &json!({"id": "stand-in"})).unwrap();
|
||
assert_eq!(r["model"], Value::Null);
|
||
assert!(x.s.denoise.active.is_none());
|
||
assert!(!x.dir.join("denoise-models").join("stand-in").exists());
|
||
// ids that are not folder names, and folders that are not models, are never touched
|
||
for bad in ["../library", "", "a/b", "settings.json"] {
|
||
assert!(x.s.execute("denoise.models.remove", &json!({"id": bad})).is_err(), "{bad}");
|
||
}
|
||
assert!(x.dir.join("library").is_dir());
|
||
// a model that fails its self-test is not left installed
|
||
x.s.denoise.loader = Arc::new(|_, _| Err("it does not load".into()));
|
||
let e = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap_err().to_string();
|
||
assert!(e.contains("it does not load"), "{e}");
|
||
assert!(!x.dir.join("denoise-models").join("stand-in").exists());
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_manifest_that_does_not_validate_or_name_the_file_is_refused() {
|
||
let mut x = setup("manifest", false);
|
||
let onnx = model_files(&x.dir, "stand-in");
|
||
let json_path = onnx.with_file_name("denoise-model.json");
|
||
// a tile the model cannot run on
|
||
let mut m = manifest("stand-in");
|
||
m.tile = 50;
|
||
std::fs::write(&json_path, serde_json::to_vec(&m).unwrap()).unwrap();
|
||
let e = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap_err().to_string();
|
||
assert!(e.contains("tile"), "{e}");
|
||
// a hash that is not the file's
|
||
let mut m = manifest("stand-in");
|
||
m.sha256 = Some("0".repeat(64));
|
||
std::fs::write(&json_path, serde_json::to_vec(&m).unwrap()).unwrap();
|
||
let e = x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap_err().to_string();
|
||
assert!(e.contains("SHA-256"), "{e}");
|
||
assert!(!x.dir.join("denoise-models").join("stand-in").join("model.onnx").exists(), "nothing half-installed is left");
|
||
// an id that would leave the folder
|
||
let mut m = manifest("../escape");
|
||
m.id = "../escape".into();
|
||
std::fs::write(&json_path, serde_json::to_vec(&m).unwrap()).unwrap();
|
||
assert!(x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).is_err());
|
||
assert!(!x.dir.join("escape").exists());
|
||
// garbage instead of a manifest
|
||
std::fs::write(&json_path, b"{ not json").unwrap();
|
||
assert!(x.s.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).is_err());
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
/// Another app's model folder (#1381): an `.onnx` with no manifest, then with that app's `model.json` beside it, then
|
||
/// with the `model.json` renamed. Each refusal says what is wrong and where LightCraft's manifest is described.
|
||
#[test]
|
||
fn inspecting_another_apps_model_folder_says_what_is_missing() {
|
||
let dir = temp_dir("denoise-foreign");
|
||
let mut s = Session::new().with_fs();
|
||
s.set_denoise_models_dir(Some(dir.join("denoise-models")));
|
||
let src = dir.join("rawdenoise");
|
||
std::fs::create_dir_all(&src).unwrap();
|
||
let onnx = src.join("rawdenoise.onnx");
|
||
std::fs::write(&onnx, b"not an onnx file").unwrap();
|
||
let mut inspect = || s.execute("denoise.models.inspect", &json!({"path": onnx.to_string_lossy()})).unwrap_err().to_string();
|
||
let e = inspect();
|
||
assert!(e.contains("No denoise-model.json beside") && e.contains("docs/denoise.md") && !e.contains("model.json there"), "{e}");
|
||
std::fs::write(src.join("model.json"), json!({"name": "rawdenoise", "input": {"channels": 4}, "scale": 2}).to_string()).unwrap();
|
||
let e = inspect();
|
||
assert!(e.contains("No denoise-model.json beside") && e.contains("The model.json there") && e.contains("docs/denoise.md"), "{e}");
|
||
std::fs::rename(src.join("model.json"), src.join("denoise-model.json")).unwrap();
|
||
let e = inspect();
|
||
assert!(e.contains("not a denoise-model manifest") && e.contains("docs/denoise.md"), "{e}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_photo_is_denoised_in_the_background_and_the_amount_mixes_it_in() {
|
||
let mut x = setup("background", true);
|
||
let id = photo(&x.s);
|
||
// nothing is made until a photo has an amount
|
||
let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
assert_eq!((r["queued"].clone(), r["running"].clone(), r["ready"].clone()), (json!(0), Value::Null, json!(0)), "{r}");
|
||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Idle);
|
||
let plain = rendered_mean(&mut x.s, id);
|
||
let key_before = x.s.render_job(id, 160, 160, false, true).unwrap().key;
|
||
|
||
set_amount(&mut x.s, 100.0);
|
||
// the photo being edited is picked up by itself, and made on another thread
|
||
let r = pump_until_ready(&mut x.s, 1);
|
||
assert_eq!(r["queued"], 0, "{r}");
|
||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
|
||
assert!(x.runs.load(Ordering::Relaxed) >= 4, "a 192 × 160 mosaic is four 64-cell tiles: {}", x.runs.load(Ordering::Relaxed));
|
||
let products: Vec<_> = std::fs::read_dir(x.dir.join("library").join("denoise")).unwrap().flatten().collect();
|
||
assert_eq!(products.len(), 1, "one cached picture, no DNG or anything else in the library");
|
||
assert!(products[0].path().extension().is_some_and(|e| e == "lcdn"));
|
||
let originals: Vec<_> = std::fs::read_dir(x.dir.join("originals")).unwrap().flatten().collect();
|
||
assert_eq!(originals.len(), 1, "the original is untouched and alone");
|
||
|
||
// the render now comes from the denoised picture: darker at 100, in between at 50, the plain one at 0
|
||
let full = rendered_mean(&mut x.s, id);
|
||
assert!(full < plain - 3.0, "denoised {full} vs plain {plain}");
|
||
set_amount(&mut x.s, 50.0);
|
||
let half = rendered_mean(&mut x.s, id);
|
||
assert!(half < plain - 1.0 && half > full + 1.0, "{full} < {half} < {plain}");
|
||
set_amount(&mut x.s, 0.0);
|
||
assert!((rendered_mean(&mut x.s, id) - plain).abs() < 0.01, "amount 0 is the plain picture again");
|
||
// …and a render that uses it has another identity than one that does not
|
||
assert_ne!(key_before, {
|
||
set_amount(&mut x.s, 100.0);
|
||
x.s.render_job(id, 160, 160, false, true).unwrap().key
|
||
});
|
||
// switching the Detail section off switches the denoise off with it
|
||
set_amount(&mut x.s, 100.0);
|
||
x.s.execute("develop.sectionEnabled", &json!({"section": "detail", "enabled": false})).unwrap();
|
||
assert!((rendered_mean(&mut x.s, id) - plain).abs() < 0.01);
|
||
assert_eq!(x.s.media.denoise_salt(id, &x.s.catalog.photo(id).unwrap().develop), 0);
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn what_was_made_is_found_again_by_a_new_session_and_a_new_model_starts_over() {
|
||
let mut x = setup("again", true);
|
||
let id = photo(&x.s);
|
||
set_amount(&mut x.s, 100.0);
|
||
pump_until_ready(&mut x.s, 1);
|
||
let made = x.runs.load(Ordering::Relaxed);
|
||
x.s.close_library().unwrap();
|
||
drop(x.s);
|
||
|
||
// reopened: ready without running the model again
|
||
let mut s2 = Session::new().with_fs();
|
||
s2.set_denoise_models_dir(Some(x.dir.join("denoise-models")));
|
||
s2.denoise.loader = dim_loader(&x.runs);
|
||
s2.open_library(x.dir.join("library"), false).unwrap();
|
||
s2.denoise_pump(crate::denoise::Pace::Full);
|
||
assert_eq!(s2.denoise_photo_state(id), PhotoState::Ready);
|
||
assert_eq!(x.runs.load(Ordering::Relaxed), made, "nothing was made again");
|
||
|
||
// another model's pictures are different pictures
|
||
let onnx = model_files(&x.dir, "other");
|
||
s2.execute("denoise.models.install", &json!({"path": onnx.to_string_lossy(), "acknowledged": true})).unwrap();
|
||
s2.denoise_pump(crate::denoise::Pace::Pause);
|
||
assert!(
|
||
matches!(s2.denoise_photo_state(id), PhotoState::Queued { .. }),
|
||
"the first model's picture is not this model's: it is to be made: {:?}",
|
||
s2.denoise_photo_state(id)
|
||
);
|
||
// and choosing the first again finds its picture where it was
|
||
s2.execute("denoise.models.select", &json!({"id": "stand-in"})).unwrap();
|
||
s2.denoise_pump(crate::denoise::Pace::Pause);
|
||
assert_eq!(s2.denoise_photo_state(id), PhotoState::Ready);
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn an_export_makes_its_picture_when_the_queue_has_not_and_never_comes_out_without_it() {
|
||
let mut x = setup("export", true);
|
||
let id = photo(&x.s);
|
||
let o = ExportOptions::from_json(&json!({"format": "png", "width": 160, "height": 160}));
|
||
let decode = |bytes: &[u8]| {
|
||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(160, 160)).unwrap();
|
||
let w = d.to_working();
|
||
w.data.iter().map(|p| f64::from(p[0] + p[1] + p[2])).sum::<f64>() / (w.data.len() * 3) as f64
|
||
};
|
||
let plain = decode(&export_photo(&mut x.s, id, &o, 1).unwrap().bytes);
|
||
assert_eq!(x.runs.load(Ordering::Relaxed), 0, "no amount, no denoise");
|
||
set_amount(&mut x.s, 100.0);
|
||
// no pump: the export makes the picture itself
|
||
let denoised = decode(&export_photo(&mut x.s, id, &o, 1).unwrap().bytes);
|
||
assert!(x.runs.load(Ordering::Relaxed) >= 4);
|
||
assert!(denoised < plain * 0.9, "denoised {denoised} vs plain {plain}");
|
||
let products = std::fs::read_dir(x.dir.join("library").join("denoise")).unwrap().flatten().count();
|
||
assert_eq!(products, 1);
|
||
// a second export finds it
|
||
let runs = x.runs.load(Ordering::Relaxed);
|
||
let again = decode(&export_photo(&mut x.s, id, &o, 1).unwrap().bytes);
|
||
assert_eq!(x.runs.load(Ordering::Relaxed), runs);
|
||
assert!((again - denoised).abs() < 1e-6);
|
||
// the pump takes in what the export made
|
||
x.s.denoise_pump(crate::denoise::Pace::Pause);
|
||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
|
||
|
||
// no model: the export says so instead of leaving the denoise out
|
||
x.s.execute("denoise.models.select", &json!({"id": null})).unwrap();
|
||
let e = export_photo(&mut x.s, id, &o, 1).err().unwrap();
|
||
assert!(e.contains("no denoise model is chosen") && e.contains("ramp.dng"), "{e}");
|
||
set_amount(&mut x.s, 0.0);
|
||
assert!(export_photo(&mut x.s, id, &o, 1).is_ok(), "without an amount nothing is asked of the model");
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
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()));
|
||
// 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();
|
||
assert!(e.contains("ramp.dng") && e.contains("the model went away"), "{e}");
|
||
// the background job fails too, says why once and does not try again every frame
|
||
for _ in 0..200 {
|
||
x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
if matches!(x.s.denoise_photo_state(id), PhotoState::Failed { .. }) {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||
}
|
||
match x.s.denoise_photo_state(id) {
|
||
PhotoState::Failed { why, unsupported } => assert!(why.contains("the model went away") && !unsupported, "{why}"),
|
||
other => panic!("{other:?}"),
|
||
}
|
||
let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
assert_eq!((r["queued"].clone(), r["running"].clone()), (json!(0), Value::Null), "{r}");
|
||
// asking again by hand does retry
|
||
x.s.denoise.loader = dim_loader(&x.runs);
|
||
let r = x.s.execute("denoise.queue", &json!({"retry": true})).unwrap();
|
||
assert_eq!(r["added"], 1, "{r}");
|
||
pump_until_ready(&mut x.s, 1);
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_corrupt_cache_with_a_current_header_never_silently_exports_the_plain_photo() {
|
||
let mut x = setup("corrupt-export", true);
|
||
let id = photo(&x.s);
|
||
set_amount(&mut x.s, 100.0);
|
||
let options = ExportOptions::from_json(&json!({"format": "png", "width": 160, "height": 160}));
|
||
export_photo(&mut x.s, id, &options, 1).unwrap();
|
||
x.s.denoise_pump(crate::denoise::Pace::Pause);
|
||
let spec = x.s.media.denoise.spec(id).unwrap().clone();
|
||
let mut bytes = std::fs::read(&spec.product).unwrap();
|
||
for byte in bytes.iter_mut().rev().take(64) {
|
||
*byte ^= 0xff;
|
||
}
|
||
std::fs::write(&spec.product, bytes).unwrap();
|
||
assert!(lightcraft_denoise::product::is_current(&spec.product, &spec.key), "header/length still look current");
|
||
x.s.media.forget(id);
|
||
let error = export_photo(&mut x.s, id, &options, 1).err().unwrap();
|
||
assert!(error.contains("AI Denoise picture could not be read"), "{error}");
|
||
let _ = std::fs::remove_dir_all(x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn only_raw_photos_on_disk_are_denoised_and_nothing_is_queued_without_a_model() {
|
||
let dir = temp_dir("eligible");
|
||
let mut demo = Session::with_demo();
|
||
demo.set_denoise_models_dir(Some(dir.join("models")));
|
||
let id = demo.active().unwrap();
|
||
assert_eq!(demo.denoise_photo_state(id), PhotoState::NotApplicable, "a generated photo has no sensor data");
|
||
let e = demo.execute("denoise.queue", &json!({})).unwrap_err().to_string();
|
||
assert!(e.contains("no denoise model is chosen"), "{e}");
|
||
// no models folder at all (the web build): the commands say so, nothing panics
|
||
let mut web = Session::with_demo();
|
||
for cmd in ["denoise.models.install", "denoise.models.select", "denoise.models.remove", "denoise.settings"] {
|
||
assert!(web.execute(cmd, &json!({"path": "x", "acknowledged": true, "id": "x"})).is_err(), "{cmd}");
|
||
}
|
||
assert_eq!(web.execute("denoise.pump", &json!({})).unwrap()["active"], false);
|
||
assert_eq!(web.execute("denoise.status", &json!({})).unwrap()["enabled"], false);
|
||
// no library open: nowhere to keep pictures, and the queue says so
|
||
let x = setup("eligible2", true);
|
||
let mut bare = Session::new().with_fs();
|
||
bare.set_denoise_models_dir(Some(x.dir.join("denoise-models")));
|
||
assert!(bare.denoise_products_dir().is_none());
|
||
let e = bare.execute("denoise.queue", &json!({})).unwrap_err().to_string();
|
||
assert!(e.contains("open a library on disk first"), "{e}");
|
||
assert_eq!(bare.execute("denoise.pump", &json!({})).unwrap()["queued"], 0);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn files_that_cannot_be_denoised_fail_with_a_reason_and_never_a_panic() {
|
||
let dir = temp_dir("makefails");
|
||
let spec = |path: &Path| crate::denoise::JobSpec {
|
||
path: path.to_string_lossy().into_owned(),
|
||
read: None,
|
||
product: dir.join("p.lcdn"),
|
||
key: "k".into(),
|
||
manifest: manifest("stand-in"),
|
||
onnx: dir.join("model.onnx"),
|
||
model: Default::default(),
|
||
loader: dim_loader(&Arc::new(AtomicUsize::new(0))),
|
||
parallel: 2,
|
||
run_on: RunOn::Auto,
|
||
};
|
||
use crate::denoise::{MakeError, make_product};
|
||
// not a file
|
||
assert!(matches!(make_product(&spec(&dir.join("missing.dng")), None), Err(MakeError::Failed(_))));
|
||
// not a raw
|
||
let junk = dir.join("junk.dng");
|
||
std::fs::write(&junk, b"this is not a raw file at all").unwrap();
|
||
assert!(matches!(make_product(&spec(&junk), None), Err(MakeError::Unsupported(_))));
|
||
// a raw cut short
|
||
let whole = ramp_dng(192, 160, "RGGB");
|
||
let cut = dir.join("cut.dng");
|
||
std::fs::write(&cut, &whole[..whole.len() / 3]).unwrap();
|
||
assert!(make_product(&spec(&cut), None).is_err());
|
||
// a model that gives the wrong number of values is an error from the model, not a crash
|
||
struct Wrong;
|
||
impl TileRunner for Wrong {
|
||
fn run(&self, _: &[f32]) -> Result<Vec<f32>, lightcraft_denoise::Error> {
|
||
Ok(vec![0.0; 7])
|
||
}
|
||
}
|
||
impl Model for Wrong {
|
||
fn runner(&self, _: RunOn, threads: usize) -> (&dyn TileRunner, usize) {
|
||
(self, threads)
|
||
}
|
||
fn self_test(&self, _: RunOn) -> Result<Value, String> {
|
||
Err("no".into())
|
||
}
|
||
}
|
||
let good = dir.join("good.dng");
|
||
std::fs::write(&good, &whole).unwrap();
|
||
let mut s = spec(&good);
|
||
s.loader = Arc::new(|_, _| Ok(Arc::new(Wrong) as Arc<dyn Model>));
|
||
match make_product(&s, None) {
|
||
Err(MakeError::Failed(why)) => assert!(why.contains("7 numbers"), "{why}"),
|
||
other => panic!("{other:?}"),
|
||
}
|
||
assert!(!dir.join("p.lcdn").exists(), "a failed job leaves no product");
|
||
// a job told to stop stops before doing anything
|
||
let progress = crate::denoise::Progress::default();
|
||
progress.cancel_now();
|
||
assert_eq!(make_product(&spec(&good), Some(&progress)), Err(MakeError::Cancelled));
|
||
// a Bayer layout of the other kinds is denoised too, and a product that exists is not made again
|
||
for cfa in ["BGGR", "GRBG", "GBRG"] {
|
||
let f = dir.join(format!("{cfa}.dng"));
|
||
std::fs::write(&f, ramp_dng(192, 160, cfa)).unwrap();
|
||
let mut s = spec(&f);
|
||
s.product = dir.join(format!("{cfa}.lcdn"));
|
||
assert_eq!(make_product(&s, None), Ok(true), "{cfa}");
|
||
assert_eq!(make_product(&s, None), Ok(false), "{cfa} is already there");
|
||
}
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn two_threads_making_the_same_picture_make_it_once() {
|
||
let dir = temp_dir("twice");
|
||
let f = dir.join("a.dng");
|
||
std::fs::write(&f, ramp_dng(192, 160, "RGGB")).unwrap();
|
||
let runs = Arc::new(AtomicUsize::new(0));
|
||
let spec = crate::denoise::JobSpec {
|
||
path: f.to_string_lossy().into_owned(),
|
||
read: None,
|
||
product: dir.join("p.lcdn"),
|
||
key: "k".into(),
|
||
manifest: manifest("stand-in"),
|
||
onnx: dir.join("model.onnx"),
|
||
model: Default::default(),
|
||
loader: dim_loader(&runs),
|
||
parallel: 2,
|
||
run_on: RunOn::Auto,
|
||
};
|
||
let made: Vec<bool> = std::thread::scope(|sc| {
|
||
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| crate::denoise::make_product(&spec, None).unwrap())).collect();
|
||
hs.into_iter().map(|h| h.join().unwrap()).collect()
|
||
});
|
||
assert_eq!(made.iter().filter(|m| **m).count(), 1, "{made:?}");
|
||
assert_eq!(runs.load(Ordering::Relaxed), 4, "one photo's four tiles, once");
|
||
assert!(lightcraft_denoise::product::is_current(&spec.product, "k"));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn the_cache_is_kept_within_its_limit_and_what_photos_use_goes_last() {
|
||
let mut x = setup("evict", true);
|
||
let id = photo(&x.s);
|
||
set_amount(&mut x.s, 100.0);
|
||
pump_until_ready(&mut x.s, 1);
|
||
let used = x.dir.join("library").join("denoise");
|
||
// two older pictures nobody uses
|
||
for (i, name) in ["aaaa", "bbbb"].iter().enumerate() {
|
||
let f = used.join(format!("{name}.lcdn"));
|
||
std::fs::write(&f, vec![7u8; 1000]).unwrap();
|
||
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(1000 - i as u64);
|
||
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
|
||
}
|
||
let size_of = |p: &Path| std::fs::metadata(p).unwrap().len();
|
||
let mine: PathBuf = std::fs::read_dir(&used).unwrap().flatten().map(|e| e.path()).find(|p| p.file_stem().is_some_and(|s| s.len() == 32)).unwrap();
|
||
// room for the photo's own picture and one stray: the oldest stray goes, then the other
|
||
x.s.denoise_evict_to(size_of(&mine) + 1500, None);
|
||
assert!(!used.join("aaaa.lcdn").exists() && used.join("bbbb.lcdn").exists() && mine.exists());
|
||
x.s.denoise_evict_to(size_of(&mine) + 10, None);
|
||
assert!(!used.join("bbbb.lcdn").exists() && mine.exists());
|
||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
|
||
// too small for even that: the photo's own picture is the last thing to go, and the photo knows
|
||
x.s.denoise_evict_to(10, None);
|
||
assert!(!mine.exists());
|
||
assert_ne!(x.s.denoise_photo_state(id), PhotoState::Ready);
|
||
// …and it is made again, because the photo still has its amount
|
||
pump_until_ready(&mut x.s, 1);
|
||
assert!(mine.exists());
|
||
// clearing deletes it all
|
||
let r = x.s.execute("denoise.clear", &json!({})).unwrap();
|
||
assert_eq!(r["deleted"], 1, "{r}");
|
||
assert_eq!(std::fs::read_dir(&used).unwrap().flatten().count(), 0);
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn settings_are_checked_saved_and_a_paused_pump_starts_nothing() {
|
||
let mut x = setup("settings", true);
|
||
let r = x.s.execute("denoise.settings", &json!({"auto": false, "cacheGb": 5, "threads": 3})).unwrap();
|
||
assert_eq!((r["auto"].clone(), r["cacheGb"].clone(), r["threads"].clone()), (json!(false), json!(5), json!(3)), "{r}");
|
||
for bad in [
|
||
json!({"cacheGb": 0}),
|
||
json!({"cacheGb": "big"}),
|
||
json!({"threads": 0}),
|
||
json!({"threads": 1000}),
|
||
json!({"auto": "yes"}),
|
||
json!({"runOn": "fastest"}),
|
||
json!({"runOn": true}),
|
||
] {
|
||
assert!(x.s.execute("denoise.settings", &bad).is_err(), "{bad}");
|
||
}
|
||
assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["cacheGb"], 5, "a refused change changes nothing");
|
||
// the faster of the card and the processor unless the user says otherwise, and the interface can read the choice back
|
||
assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["runOn"], "auto");
|
||
assert_eq!(x.s.execute("denoise.settings", &json!({"runOn": "cpu"})).unwrap()["runOn"], "cpu");
|
||
assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["runOn"], "cpu");
|
||
let status = x.s.execute("denoise.status", &json!({})).unwrap();
|
||
assert_eq!((status["runOn"].clone(), status["device"]["kind"].clone()), (json!("cpu"), json!("none")), "no model is loaded yet: {status}");
|
||
// the setting before `runOn` still reads as what it meant
|
||
let models = x.s.denoise.models_dir.clone().unwrap();
|
||
let mut st = crate::denoise::read_settings(&models);
|
||
(st.run_on, st.gpu) = (None, Some(false));
|
||
crate::denoise::write_settings(&models, &st).unwrap();
|
||
assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["runOn"], "cpu");
|
||
// choosing where it runs lets the card be tried again after a set-up that closed LightCraft
|
||
let onnx = crate::denoise::installed_models(&models).first().unwrap().onnx.clone();
|
||
let marker = onnx.with_file_name(crate::denoise::GPU_SETUP_MARKER);
|
||
std::fs::write(&marker, "setting up").unwrap();
|
||
assert_eq!(x.s.execute("denoise.settings", &json!({"runOn": "auto"})).unwrap()["runOn"], "auto");
|
||
assert!(!marker.exists(), "the crash is forgotten");
|
||
assert_eq!(crate::denoise::read_settings(&models).gpu, None, "the old setting is not kept beside the new one");
|
||
// with auto off nothing is started by itself, and the photo is made when asked
|
||
let id = photo(&x.s);
|
||
set_amount(&mut x.s, 100.0);
|
||
for _ in 0..3 {
|
||
x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
}
|
||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Idle);
|
||
x.s.execute("denoise.settings", &json!({"auto": true})).unwrap();
|
||
// paused: it is queued but nothing starts
|
||
for _ in 0..3 {
|
||
let r = x.s.execute("denoise.pump", &json!({"pace": "pause"})).unwrap();
|
||
assert_eq!(r["running"], Value::Null);
|
||
}
|
||
assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Queued { .. }), "{:?}", x.s.denoise_photo_state(id));
|
||
// …and cancelling forgets it
|
||
assert_eq!(x.s.execute("denoise.cancel", &json!({})).unwrap()["cancelled"], 1);
|
||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Idle);
|
||
// when the pace allows, it is made
|
||
x.s.execute("denoise.queue", &json!({"scope": "withAmount"})).unwrap();
|
||
pump_until_ready(&mut x.s, 1);
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_photo_whose_file_changed_has_no_picture_until_it_is_made_again() {
|
||
let mut x = setup("changed", true);
|
||
let id = photo(&x.s);
|
||
set_amount(&mut x.s, 100.0);
|
||
pump_until_ready(&mut x.s, 1);
|
||
// the photo's content is something else now (a replaced file): its old picture is not its picture
|
||
let p = x.s.catalog.photo(id).unwrap().clone();
|
||
x.s.commit(
|
||
"test",
|
||
lightcraft_catalog::Op::SetContent {
|
||
id,
|
||
width: p.width,
|
||
height: p.height,
|
||
file_size: p.file_size + 1,
|
||
content_hash: Some("ffffffffffffffffffffffffffffffff".into()),
|
||
preview_only: None,
|
||
preview_checked: String::new(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
x.s.denoise_pump(crate::denoise::Pace::Pause);
|
||
assert_ne!(x.s.denoise_photo_state(id), PhotoState::Ready);
|
||
// the old picture is not used by a render either
|
||
assert_eq!(x.s.media.denoise_salt(id, &x.s.catalog.photo(id).unwrap().develop), 0);
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_photo_waits_for_its_turn_unless_pictures_are_not_made_on_their_own_or_have_nowhere_to_go() {
|
||
let mut x = setup("waits", true);
|
||
let id = photo(&x.s);
|
||
set_amount(&mut x.s, 80.0);
|
||
// automatic off: the open photo is left alone until it is asked for
|
||
x.s.execute("denoise.settings", &json!({"auto": false})).unwrap();
|
||
assert!(!x.s.denoise_auto());
|
||
x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Idle);
|
||
assert!(!x.s.denoise_busy());
|
||
x.s.execute("denoise.queue", &json!({"ids": [id.0]})).unwrap();
|
||
assert!(x.s.denoise_busy(), "queued with a model: the headless runs wait for it");
|
||
pump_until_ready(&mut x.s, 1);
|
||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
|
||
assert!(!x.s.denoise_busy());
|
||
// a session whose library is not on disk has nowhere to keep it: the photo says so instead of waiting for ever
|
||
x.s.denoise.settings.auto = Some(true);
|
||
if let Some(l) = x.s.library.as_mut() {
|
||
l.on_disk = false;
|
||
}
|
||
match x.s.denoise_photo_state(id) {
|
||
PhotoState::Failed { why, unsupported } => assert!(unsupported && why.contains("library folder"), "{why}"),
|
||
other => panic!("{other:?}"),
|
||
}
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_picture_that_appears_while_its_job_runs_is_ready_only_when_the_job_reports() {
|
||
// #719: the worker saves the picture, then tears down its thread pool before it reports. A pump in that window
|
||
// used to find the file and mark the photo Ready while the session still counted the job as running: Ready and
|
||
// busy at once, and the activity row stayed up for a photo that said it was done.
|
||
use std::sync::{Mutex, mpsc};
|
||
use std::time::Duration;
|
||
let mut x = setup("ready-while-running", true);
|
||
let id = photo(&x.s);
|
||
set_amount(&mut x.s, 80.0);
|
||
// the job loads the model afresh and waits in the loader until it is released: a worker that is at work
|
||
*x.s.denoise.active.as_ref().unwrap().model.lock().unwrap() = None;
|
||
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();
|
||
x.s.denoise.loader = Arc::new(move |_, _| {
|
||
entered_tx.send(()).map_err(|e| e.to_string())?;
|
||
release_rx.lock().unwrap().recv_timeout(Duration::from_secs(10)).map_err(|e| e.to_string())?;
|
||
Ok(Arc::new(Dim { runs: runs.clone() }) as Arc<dyn Model>)
|
||
});
|
||
x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||
assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Running { .. }));
|
||
assert!(x.s.denoise_busy());
|
||
// the picture lands on disk before the worker says so (as it does between its save and its report)
|
||
let (product, key) = x.s.denoise_running_product().unwrap();
|
||
let mut rgb = lightcraft_raster::Rgb32f::new(192, 160);
|
||
rgb.data.fill([0.25, 0.25, 0.25]);
|
||
lightcraft_denoise::product::write(&product, &rgb, &key).unwrap();
|
||
assert!(lightcraft_denoise::product::is_current(&product, &key));
|
||
x.s.denoise.touch(); // the index is also rebuilt from the folder in this window
|
||
let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
assert!(matches!(x.s.denoise_photo_state(id), PhotoState::Running { .. }), "the job reports its own picture");
|
||
assert_eq!(r["ready"], 0, "{r}");
|
||
assert!(x.s.denoise_busy());
|
||
// the worker reports: ready and idle in the same pump
|
||
release_tx.send(()).unwrap();
|
||
let r = pump_until_ready(&mut x.s, 1);
|
||
assert_eq!(x.s.denoise_photo_state(id), PhotoState::Ready);
|
||
assert_eq!(r["running"], Value::Null, "{r}");
|
||
assert!(!x.s.denoise_busy());
|
||
drop(x.s);
|
||
let _ = std::fs::remove_dir_all(x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn the_denoise_switch_validates_arguments_and_accepts_explicit_targets_without_selection() {
|
||
let mut x = setup("toggle", false);
|
||
let id = photo(&x.s);
|
||
for params in [json!({"id": "bad"}), json!({"id": u64::MAX}), json!({"id": id.0, "enabled": "yes"})] {
|
||
assert!(x.s.execute("denoise.toggle", ¶ms).is_err());
|
||
}
|
||
assert!(x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).is_err());
|
||
assert_eq!(x.s.develop_of(id).unwrap().enhance.denoise, 0.0);
|
||
let model = model_files(&x.dir, "toggle-model");
|
||
x.s.execute("denoise.models.install", &json!({"path": model.to_string_lossy(), "acknowledged": true})).unwrap();
|
||
x.s.selection = Default::default();
|
||
let undo = x.s.undo.len();
|
||
let r = x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).unwrap();
|
||
assert_eq!(r["amount"], 50.0);
|
||
assert_eq!(x.s.undo.len(), undo + 1);
|
||
x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": false})).unwrap();
|
||
assert_eq!(x.s.develop_of(id).unwrap().denoise_amount(), 0.0);
|
||
// the switch is its own setting: flipping it keeps the Amount (so it compares with and without), and an Amount of 0
|
||
// does not switch it off
|
||
x.s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||
x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).unwrap();
|
||
x.s.execute("develop.set", &json!({"control": "enhance.denoise", "value": 80.0})).unwrap();
|
||
let off = x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": false})).unwrap();
|
||
assert_eq!(off["amount"], 80.0);
|
||
let d = x.s.develop_of(id).unwrap();
|
||
assert!(!d.enhance.denoise_enabled() && d.denoise_amount() == 0.0);
|
||
assert_eq!(x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).unwrap()["amount"], 80.0);
|
||
assert!((x.s.develop_of(id).unwrap().denoise_amount() - 0.8).abs() < 1e-6);
|
||
x.s.execute("develop.set", &json!({"control": "enhance.denoise", "value": 0.0})).unwrap();
|
||
assert!(x.s.develop_of(id).unwrap().enhance.denoise_enabled(), "sliding to 0 leaves it on");
|
||
assert_eq!(x.s.execute("denoise.toggle", &json!({"id": id.0, "enabled": true})).unwrap()["amount"], 0.0);
|
||
// with no `enabled`, the toggle flips what the switch says
|
||
assert_eq!(x.s.execute("denoise.toggle", &json!({"id": id.0})).unwrap()["enabled"], false);
|
||
drop(x.s);
|
||
let _ = std::fs::remove_dir_all(x.dir);
|
||
}
|
||
|
||
#[test]
|
||
fn denoise_row_while_queue_is_busy() {
|
||
let mut x = setup("activity", true);
|
||
let rows = |s: &Session| s.activity.list().into_iter().filter(|t| t.kind == "denoise").collect::<Vec<_>>();
|
||
let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
assert!(rows(&x.s).is_empty(), "nothing to make, no row: {r}");
|
||
set_amount(&mut x.s, 100.0);
|
||
let mut shown = false;
|
||
for _ in 0..600 {
|
||
let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap();
|
||
let rows = rows(&x.s);
|
||
// (what the pump left running or queued is what the row reflects until the next pump)
|
||
if x.s.denoise_busy() {
|
||
assert_eq!(rows.len(), 1, "{r}");
|
||
assert_eq!((rows[0].label.as_str(), rows[0].cancellable, rows[0].unit), ("AI Denoise", false, crate::activity::Unit::Percent), "{r}");
|
||
if !r["running"].is_null() {
|
||
assert_eq!(rows[0].detail, "ramp.dng", "{r}");
|
||
}
|
||
shown = true;
|
||
} else {
|
||
assert!(rows.is_empty(), "{r}");
|
||
}
|
||
if r["ready"].as_u64() >= Some(1) {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||
}
|
||
assert!(shown, "the photo never showed a row");
|
||
assert!(rows(&x.s).is_empty(), "the row goes once the photo is made");
|
||
let _ = std::fs::remove_dir_all(&x.dir);
|
||
}
|