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

Generated with Codex.
This commit is contained in:
Daniel Voshart
2026-10-08 22:07:23 -04:00
parent 7935999d94
commit fd4019300a
59 changed files with 1437 additions and 217 deletions
+3 -2
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(
+2
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…).
+7 -6
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,
}
}
+98 -57
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));
}
+1 -1
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);
+33 -2
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 {