Browse / github.com/storytold/lightcraft / patch
LR-EDIT-OPTICS-PROFILE: the lens database downloads by itself on first run, the library is looked up, Optics names the profile
LR-EDIT-OPTICS-PROFILE: the lens database downloads by itself on first run, the library is looked up, Optics names the profile Part of #329. Refs #3032, #2192, #2939, #2511. ## Why In 0.14.0 a raw is corrected from a lens database only when the user has set `LIGHTCRAFT_LENSFUN_DIR`. Nobody outside this repo knows to do that, so most raws get "No lens data embedded in this file." even for common lenses lensfun knows. That is what #3032 (Canon 250D CR2, reported on Discord), #2192, #2939 and #2511 ran into. ## What - **The lens database without setup.** On first run the desktop app downloads lensfun's `data/db` (56 XML files, about 5 MB, CC BY-SA 3.0) into `<config dir>/lensfun`, with a row in the activity s
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- OneSlips (imported from pull request #3073, open)
- Base
3a3e49af53aeff55248419234a05d1a262094092- Size
- +1814 −94 in 58 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a6df8307da7fc206faba42d5
How it went
No other operator has reported using it yet.
Diff
raw · apps/lightcraft-cli/src/main.rs apps/lightcraft/src/session.rs crates/catalog/src/journal.rs crates/catalog/src/tests.rs crates/catalog/src/tests_format_version.rs crates/develop/src/settings.rs crates/engine/src/cmd.rs crates/engine/src/cmd/convert.rs crates/engine/src/cmd/lens_refresh.rs crates/engine/src/config.rs crates/engine/src/lens_profiles.rs crates/engine/src/lensfun_download.rs crates/engine/src/lib.rs crates/engine/src/media/quick.rs crates/engine/src/media/session.rs crates/engine/src/media/source.rs crates/engine/src/tests.rs crates/engine/src/tests/lens_refresh.rs crates/gpu/src/render.rs crates/gpu/tests/it/equivalence.rs crates/mcp/src/headless.rs crates/pipeline/src/optics.rs crates/ui-egui/locales/cs-formats.json crates/ui-egui/locales/cs.json crates/ui-egui/locales/de-formats.json crates/ui-egui/locales/de.json crates/ui-egui/locales/es-formats.json crates/ui-egui/locales/es.json crates/ui-egui/locales/fr-formats.json crates/ui-egui/locales/fr.json crates/ui-egui/locales/it-formats.json crates/ui-egui/locales/it.json crates/ui-egui/locales/ja-formats.json crates/ui-egui/locales/ja.json crates/ui-egui/locales/ka-formats.json crates/ui-egui/locales/ka.json crates/ui-egui/locales/pl-formats.json crates/ui-egui/locales/pl.json crates/ui-egui/locales/pt-br-formats.json crates/ui-egui/locales/pt-br.json crates/ui-egui/locales/ru-formats.json crates/ui-egui/locales/ru.json crates/ui-egui/locales/uk-formats.json crates/ui-egui/locales/uk.json crates/ui-egui/locales/zh-hans-formats.json crates/ui-egui/locales/zh-hans.json crates/ui-egui/locales/zh-hant-formats.json crates/ui-egui/locales/zh-hant.json crates/ui-egui/src/app/frame.rs crates/ui-egui/src/lens_db.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/panels/edit.rs crates/ui-egui/src/state.rs crates/ui-egui/src/tests.rs crates/ui-egui/src/tests/lens_lookup.rs docs/gaps.md docs/lens-profiles.md docs/parity-checklist.md
@@ -416,7 +416,7 @@ fn merge(args: &[String]) -> Result<(), String> { } i += 1; }- let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();+ let mut s = file_session(); let paths = expand_paths(&files); let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; let mut ids: Vec<u64> = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();@@ -667,6 +667,12 @@ fn read_settings(p: &str) -> Result<SettingsFile, String> { } } +/// The session `render` and `merge` develop files in: with the lens database the desktop app downloaded, when it is+/// there (never fetched from here: scripts and tests stay offline).+fn file_session() -> Session {+ Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_lens_database()+}+ fn render(args: &[String]) -> Result<(), String> { let mut input = None; let mut output = None;@@ -703,7 +709,7 @@ fn render(args: &[String]) -> Result<(), String> { } let input = input.ok_or("render: missing input file")?; let output = output.ok_or("render: missing -o OUTPUT")?;- let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();+ let mut s = file_session(); let abs = expand_paths(std::slice::from_ref(&input)); let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?; let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;@@ -798,15 +804,30 @@ fn snapshot(args: &[String]) -> Result<(), String> { let t0 = Instant::now(); let mut session = match &library { Some(dir) => {- let mut s =- Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates();+ let mut s = Session::new()+ .with_fs()+ .with_default_denoise_models()+ .with_system_clock()+ .with_default_face_models()+ .with_default_lens_database()+ .with_default_slide_templates(); s.open_library(dir, false).map_err(|e| library_error(dir, e))?; s }- None if files.is_empty() => {- Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates()- }- None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(),+ None if files.is_empty() => Session::with_demo()+ .with_fs()+ .with_default_denoise_models()+ .with_system_clock()+ .with_default_face_models()+ .with_default_lens_database()+ .with_default_slide_templates(),+ None => Session::new()+ .with_fs()+ .with_default_denoise_models()+ .with_system_clock()+ .with_default_face_models()+ .with_default_lens_database()+ .with_default_slide_templates(), }; // Apply compute policy before imports or the first scripted query can discover an adapter. session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;@@ -1028,3 +1049,15 @@ fn denoise_serve(args: &[String]) -> Result<(), String> { fn denoise_serve(_: &[String]) -> Result<(), String> { Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into()) }++#[cfg(test)]+mod tests {+ /// `render` and `merge` read the lens database the desktop app downloaded, as the desktop does (the same raw+ /// gets the same profile everywhere), but never fetch it.+ #[test]+ fn render_reads_the_downloaded_lens_database() {+ let s = super::file_session();+ assert_eq!(lightcraft_engine::lens_profiles::host_dir(), lightcraft_engine::lensfun_download::default_dir());+ assert!(!s.lens_auto_download, "selecting it is not permission to download it");+ }+}@@ -254,6 +254,10 @@ pub(crate) fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None), Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo), };+ // lens profiles from the app's own lensfun copy, fetched by itself on first run (not for a throwaway session)+ if !in_memory {+ s = s.with_default_lens_database().with_lens_auto_download();+ } s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); // Control requests are answered on the UI thread: Add to Apple Photos must not wait there@@ -52,6 +52,7 @@ //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | //! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` | //! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` |+//! | 10 | lens database profiles named in the Optics panel | `EmbeddedLens.source` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a@@ -76,7 +77,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.-pub const VERSION: u32 = 9;+pub const VERSION: u32 = 10; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)]@@ -564,7 +564,8 @@ fn set_embedded_lens_is_undoable_and_journaled() { photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")), }) .unwrap();- let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default() };+ let lens =+ lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None, stages: Default::default(), source: Default::default() }; let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) }; let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap(); let inv = c.apply(op).unwrap();@@ -238,8 +238,8 @@ fn op_variants_are_versioned() { Op::SetHistoryCursor { .. } => 8, } }- /// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`.- const NEWEST_FIELDS: u32 = 9;+ /// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`; v10: `EmbeddedLens.source`.+ const NEWEST_FIELDS: u32 = 10; let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version"); }@@ -698,11 +698,32 @@ impl Default for Optics { pub struct EmbeddedLens { pub warp: Option<EmbeddedWarp>, pub vignette: Option<EmbeddedVignette>,+ /// Where the correction came from: the file itself, or a lens database looked up by the+ /// lens named in the file (what the Optics panel says is loaded).+ #[serde(skip_serializing_if = "LensSource::is_file")]+ pub source: LensSource, /// A DNG 1.6 `WarpRectilinear2` chain (up to [`WARP_STAGES`] stages, in `OpcodeList3` order; each stage warps /// the result of the one before it). When any is set, `warp` is `None`: the chain holds every warp opcode. pub stages: [Option<EmbeddedWarpStage>; WARP_STAGES], } +/// Where an [`EmbeddedLens`] came from.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]+#[serde(rename_all = "camelCase")]+pub enum LensSource {+ /// The file's own correction (DNG opcodes, a camera's correction a raw reader converts).+ #[default]+ File,+ /// A profile from a lensfun-format lens database, matched by the lens named in the file.+ Database,+}++impl LensSource {+ pub fn is_file(&self) -> bool {+ *self == LensSource::File+ }+}+ /// Most warp stages an [`EmbeddedLens`] chain keeps. pub const WARP_STAGES: usize = 3; @@ -22,6 +22,7 @@ pub mod filters; mod focus; pub mod geocode; pub mod keywords;+pub mod lens_refresh; pub mod library; pub mod lut_profiles; pub mod manage;@@ -168,6 +169,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(browse::specs()); v.extend(missing::specs()); v.extend(recheck::specs());+ v.extend(lens_refresh::specs()); v.extend(sync::specs()); v.extend(metadata::specs()); v.extend(geocode::specs());@@ -456,6 +456,7 @@ mod tests { warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }), vignette: None, stages: Default::default(),+ source: Default::default(), } } fn old_rw2() -> lightcraft_catalog::Photo {new file mode 100644@@ -0,0 +1,205 @@+//! Lens profiles looked up again when the lens database appears or changes. A photo's lens data is+//! stored when it is imported ([`Photo::embedded_lens`]), so photos imported before the database was there say "no+//! profile" until something reads their file again. When the database arrives (the first-run download) or is updated,+//! and when "Enable Lens Corrections" is ticked on a photo without a profile, every library raw that has no profile+//! but whose lens the database knows by name is probed again and gets what Reload from Disk would give it: the+//! profile, and the correction switched on where its settings are still what import gave it (an edited photo keeps+//! its settings).+//!+//! Split like [`super::recheck`] so the app probes on a worker thread: [`missing`] lists what to probe (no+//! file-system calls), [`probe`] reads the files without the session, [`apply`] applies the results as LightCraft's+//! own bookkeeping (journaled, not an undo step). Agents and tests use `library.refreshLensProfiles`.++use lightcraft_catalog::{Catalog, Op, Photo, PhotoId};+use lightcraft_develop::LensSource;+use serde_json::{Value, json};++use super::{CommandSpec, always, bad, cmd};+use crate::media::{FileProbe, ProbeInfo};+use crate::{Result, Session};++/// A photo to probe again, and its file when listed (a result for a photo moved meanwhile is dropped).+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Missing {+ pub id: PhotoId,+ pub path: String,+}++/// Library raws with a file, no lens profile and a lens the database `db` knows by name (`ids`: only these; virtual+/// copies follow the photo they were copied from). With `again` (the database's files were replaced, or an agent+/// asked for every photo), also the raws whose profile came from the database (the new data may give another one, or+/// none) and the raws whose file named no lens when they were imported (a newer LightCraft may read their lens).+/// No file-system calls.+pub fn missing(cat: &Catalog, db: &lightcraft_lensdb::LensDb, ids: Option<&[PhotoId]>, again: bool) -> Vec<Missing> {+ // a photo and its virtual copies list the photo once, in library order+ let mut seen = std::collections::HashSet::new();+ let from_database = |p: &Photo| p.embedded_lens.is_some_and(|l| l.source == LensSource::Database);+ cat.photos()+ .filter(|p| ids.is_none_or(|ids| ids.contains(&p.id) || p.copy_of.is_some_and(|c| ids.contains(&c))))+ .map(|p| p.copy_of.and_then(|c| cat.photo(c)).map_or(&**p, |orig| &**orig))+ .filter(|p| p.develops_raw())+ .filter(|p| {+ let lens = p.meta.lens.trim();+ match &p.embedded_lens {+ None if lens.is_empty() => again,+ None => crate::lens_profiles::profile_name(db, &p.meta.camera, lens).is_some(),+ Some(_) => again && from_database(p),+ }+ })+ .filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Missing { id: p.id, path: path.to_string() }))+ .filter(|m| seen.insert(m.id))+ .collect()+}++/// Probe each listed photo's file as Reload would. Doesn't touch the session.+pub fn probe(probe: &FileProbe, list: Vec<Missing>) -> Vec<(Missing, std::result::Result<ProbeInfo, String>)> {+ let paths: Vec<String> = list.iter().map(|x| x.path.clone()).collect();+ list.into_iter().zip(crate::import::probe_with(probe, &paths)).collect()+}++/// Apply [`probe`]'s results: each photo and its virtual copies get the lens data their file gives now, in one batch+/// that is journaled but isn't an undo step: a profile found (or a different one from a replaced database), a+/// database profile the database no longer gives removed, and the lens's name where the catalog had none. Returns+/// the photos that gained or changed a profile.+pub fn apply(s: &mut Session, results: Vec<(Missing, std::result::Result<ProbeInfo, String>)>) -> Result<Vec<PhotoId>> {+ let mut ops = Vec::new();+ let mut found = Vec::new();+ let mut changed = Vec::new();+ for (item, info) in results {+ let Ok(info) = info else { continue };+ // the photo moved or left the library while its file was read: the result is stale, for its copies too+ if s.catalog.photo(item.id).and_then(|p| crate::cmd::missing::checked_path(p)) != Some(item.path.as_str()) {+ continue;+ }+ let family: Vec<PhotoId> = s.catalog.photos().filter(|p| p.id == item.id || p.copy_of == Some(item.id)).map(|p| p.id).collect();+ for id in family {+ let Some(p) = s.catalog.photo(id) else { continue };+ // nothing found for a photo without a database profile: a file's own correction is Reload's business+ let from_database = p.embedded_lens.is_some_and(|l| l.source == LensSource::Database);+ if info.embedded_lens.is_some() || from_database {+ let mine = crate::cmd::convert::lens_ops(id, p, &info);+ if !mine.is_empty() {+ ops.extend(mine);+ changed.push(id);+ if info.embedded_lens.is_some() {+ found.push(id);+ }+ }+ }+ let named = info.meta.lens.trim();+ if p.meta.lens.trim().is_empty() && !named.is_empty() {+ let mut meta = p.meta.clone();+ meta.lens = named.to_string();+ ops.push(Op::SetMeta { id, meta: Box::new(meta) });+ }+ }+ }+ if !ops.is_empty() {+ s.apply_system(Op::Batch { ops })?;+ }+ for id in &changed {+ s.media.forget(*id);+ }+ Ok(found)+}++/// Why a photo has no lens profile, in words for the Optics panel: `None` when it has one.+pub fn no_profile_reason(p: &Photo) -> Option<String> {+ if p.embedded_lens.is_some() {+ return None;+ }+ let lens = p.meta.lens.trim();+ Some(if lens.is_empty() { "This file names no lens.".to_string() } else { format!("No lens profile found for {lens}.") })+}++fn refresh(s: &mut Session, p: &Value) -> Result<Value> {+ const C: &str = "library.refreshLensProfiles";+ let fp = super::recheck::file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?;+ let ids: Option<Vec<PhotoId>> = match p.get("ids") {+ None | Some(Value::Null) => None,+ Some(Value::Array(a)) => Some(a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| bad(C, "ids are photo ids"))).collect::<Result<_>>()?),+ Some(_) => return Err(bad(C, "ids is a list of photo ids")),+ };+ let again = match p.get("all") {+ None | Some(Value::Null) => false,+ Some(Value::Bool(b)) => *b,+ Some(_) => return Err(bad(C, "all is true or false")),+ };+ let Some(db) = crate::lens_profiles::database() else {+ return Ok(json!({"checked": 0, "found": [], "database": false}));+ };+ let listed = missing(&s.catalog, &db, ids.as_deref(), again);+ let n = listed.len();+ let found = apply(s, probe(&fp, listed))?;+ Ok(json!({"checked": n, "found": found.iter().map(|i| i.0).collect::<Vec<_>>(), "database": true}))+}++fn download(s: &mut Session, _: &Value) -> Result<Value> {+ const C: &str = "lens.downloadDatabase";+ let dir = crate::lensfun_download::default_dir().ok_or_else(|| bad(C, "this session has no data folder"))?;+ start_download(s, &dir, crate::lensfun_download::mirrors(), crate::lensfun_download::FILES).map_err(|e| bad(C, e))?;+ Ok(json!({"started": true, "dir": dir.to_string_lossy()}))+}++/// Fetch the database into `dir` and select it, so this session reads it once it is in place (a session that+/// selected no database, e.g. a bare headless one, would otherwise download it and go on without it; the user's+/// `LIGHTCRAFT_LENSFUN_DIR` still wins). Until the download is whole the folder has no marker and isn't read.+pub(crate) fn start_download(+ s: &mut Session,+ dir: &std::path::Path,+ mirrors: Vec<String>,+ files: &'static [crate::lensfun_download::Pin],+) -> std::result::Result<(), String> {+ let activity = s.activity.clone();+ s.lensfun.start(dir, mirrors, files, &activity)?;+ if crate::lens_profiles::host_dir().as_deref() != Some(dir) {+ crate::lens_profiles::use_dir(Some(dir.to_path_buf()));+ }+ Ok(())+}++fn status(s: &mut Session, _: &Value) -> Result<Value> {+ use crate::lensfun_download::State;+ let state = match s.lensfun.state() {+ State::Idle => json!({"state": "idle"}),+ State::Running { bytes, total } => json!({"state": "running", "bytes": bytes, "total": total}),+ State::Done { files } => json!({"state": "done", "files": files}),+ State::Failed(e) => json!({"state": "failed", "error": e}),+ State::Cancelled => json!({"state": "cancelled"}),+ };+ let dir = crate::lens_profiles::dir();+ let lenses = crate::lens_profiles::database().map_or(0, |db| db.lenses.len());+ Ok(json!({"download": state, "dir": dir.map(|d| d.to_string_lossy().to_string()), "lenses": lenses}))+}++pub fn specs() -> Vec<CommandSpec> {+ vec![+ cmd!(+ "library.refreshLensProfiles",+ "Look Up Lens Profiles Again",+ [],+ None,+ "{ids?: [photo ids], all?: bool} — library raws without a lens profile whose lens the lens database knows are read again and get their profile (the correction is switched on where the photo's settings are still as imported); with all, also raws whose profile came from the database (it may give another, or none) and raws whose file named no lens at import; the app does this by itself when the database arrives (all: when its files were replaced) and when Enable Lens Corrections is ticked; not an undo step → {checked, found: [ids], database: bool}",+ always,+ refresh+ ),+ cmd!(+ "lens.downloadDatabase",+ "Download the Lens Database",+ [],+ None,+ "{} — fetch lensfun's lens database (CC BY-SA 3.0, pinned to one commit, checked file by file) into the app's data folder in the background, with an activity row, and read lens profiles from it once it is in place (unless LIGHTCRAFT_LENSFUN_DIR names another folder); the desktop app does this by itself on first run. LIGHTCRAFT_LENSFUN_URL lists other places to fetch the same files from → {started, dir}",+ always,+ download+ ),+ cmd!(+ "lens.databaseStatus",+ "Lens Database Status",+ [],+ None,+ "{} → {download: {state: idle|running|done|failed|cancelled, …}, dir, lenses (in the database in use)}",+ always,+ status+ ),+ ]+}@@ -146,6 +146,26 @@ impl Session { self } + /// Read lens profiles from the app's own copy of the lens database ([`crate::lensfun_download::default_dir`],+ /// unless `LIGHTCRAFT_LENSFUN_DIR` names another) when it is there. Every host with a file system selects it+ /// (desktop, CLI, headless MCP), so the same raw gets the same profile everywhere; it never fetches anything+ /// ([`Self::with_lens_auto_download`] is that permission).+ pub fn with_default_lens_database(self) -> Self {+ let dir = crate::lensfun_download::default_dir();+ if let Some(d) = &dir {+ crate::lensfun_download::recover(d);+ }+ crate::lens_profiles::use_dir(dir);+ self+ }++ /// Let the host fetch the lens database by itself when it is missing ([`crate::lensfun_download::fetch_on_start`]+ /// decides when). Separate from selecting a database that is already there ([`Self::with_default_lens_database`]).+ pub fn with_lens_auto_download(mut self) -> Self {+ self.lens_auto_download = true;+ self+ }+ /// Keep denoise models in the shared default folder ([`default_denoise_models_dir`]). pub fn with_default_denoise_models(mut self) -> Self { self.set_denoise_models_dir(default_denoise_models_dir());@@ -1,38 +1,129 @@-//! Lens profile corrections from a lens database the user supplies ([`lightcraft_lensdb`]).+//! Lens profile corrections from a lens database ([`lightcraft_lensdb`]). //! //! When a raw carries no lens correction of its own (DNG opcodes, or a camera correction a raw reader converts,-//! [`crate::files::embedded_lens`]), the lens named in its EXIF is looked up in a lensfun-format database: the-//! directory in `LIGHTCRAFT_LENSFUN_DIR` (e.g. lensfun's `data/db`, CC-BY-SA, which is never bundled with the app).-//! The profile is converted to the same [`lightcraft_develop::EmbeddedLens`] form, so "Enable Profile Corrections", its-//! amount sliders, the GPU path and DNG export treat both alike. Without the variable nothing changes.-use std::path::PathBuf;-use std::sync::OnceLock;--use lightcraft_develop::EmbeddedLens;+//! [`crate::files::embedded_lens`]), the lens named in its EXIF is looked up in a lensfun-format database. The+//! database is lensfun's `data/db` (CC BY-SA 3.0): never bundled with the app, it is downloaded at run time into the+//! app's data folder ([`crate::lensfun_download`], which the desktop app starts by itself on first run), or it is the+//! directory in `LIGHTCRAFT_LENSFUN_DIR` (which wins). The profile is converted to the same+//! [`lightcraft_develop::EmbeddedLens`] form (marked [`LensSource::Database`]), so "Enable Profile Corrections", its+//! amount sliders, the GPU path and DNG export treat both alike. Without a database nothing changes.+use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::{Arc, PoisonError, RwLock};++use lightcraft_develop::{EmbeddedLens, LensSource}; use lightcraft_lensdb::{LensDb, Shot}; /// The environment variable naming the lens database directory. pub const ENV: &str = "LIGHTCRAFT_LENSFUN_DIR"; -/// The configured database, read once per process; `None` when none is configured or it holds no lenses.-pub fn database() -> Option<&'static LensDb> {- static DB: OnceLock<Option<LensDb>> = OnceLock::new();- DB.get_or_init(|| {- let dir = PathBuf::from(std::env::var_os(ENV)?);- match LensDb::load_dir(&dir) {- Ok((db, errors)) => {- for (file, e) in errors {- log::warn!("lens database: skipped {file}: {e}");- }- (!db.lenses.is_empty()).then_some(db)- }- Err(e) => {- log::warn!("{e}");- None+/// The database folder a host set up ([`use_dir`]), read when `LIGHTCRAFT_LENSFUN_DIR` is not set.+static HOST_DIR: RwLock<Option<PathBuf>> = RwLock::new(None);+/// The loaded database and the folder it was read from (`None` inside: nothing usable there).+static LOADED: RwLock<Option<(PathBuf, Option<Arc<LensDb>>)>> = RwLock::new(None);+/// Bumped whenever the database in use changes ([`reload`]): photos looked up before may now find a profile.+static GENERATION: AtomicU64 = AtomicU64::new(0);+/// Bumped when the database's files were replaced ([`replaced`]): profiles found before may now be different.+static REPLACED: AtomicU64 = AtomicU64::new(0);++/// Use `dir` (the app's downloaded lensfun data, [`crate::lensfun_download::default_dir`]) when+/// `LIGHTCRAFT_LENSFUN_DIR` is not set. Hosts with a file system call it; tests and sessions that don't stay+/// without a database.+pub fn use_dir(dir: Option<PathBuf>) {+ *HOST_DIR.write().unwrap_or_else(PoisonError::into_inner) = dir;+ reload();+}++/// The database's files were replaced (a download put a new database in place): read it again, and photos whose+/// profile came from the old one are looked up again too ([`replacements`]).+pub fn replaced() {+ REPLACED.fetch_add(1, Ordering::Relaxed);+ reload();+}++/// Changes whenever the database's files were replaced ([`replaced`]); selecting a folder ([`use_dir`]) or reading+/// it again doesn't change it, so photos with a profile are not read again at every start.+pub fn replacements() -> u64 {+ REPLACED.load(Ordering::Relaxed)+}++/// The folder the host selected ([`use_dir`]), whatever `LIGHTCRAFT_LENSFUN_DIR` says.+pub fn host_dir() -> Option<PathBuf> {+ HOST_DIR.read().unwrap_or_else(PoisonError::into_inner).clone()+}++/// Read the database again (after a download or an update): the next lookup sees the new data.+pub fn reload() {+ // under the cache lock, so a load of the old files that finishes meanwhile can't be published ([`database`])+ let mut loaded = LOADED.write().unwrap_or_else(PoisonError::into_inner);+ *loaded = None;+ GENERATION.fetch_add(1, Ordering::Relaxed);+}++/// Changes whenever the database in use may have changed.+pub fn generation() -> u64 {+ GENERATION.load(Ordering::Relaxed)+}++/// The folder the database is read from: `LIGHTCRAFT_LENSFUN_DIR`, else the host's ([`use_dir`]).+pub fn dir() -> Option<PathBuf> {+ source().map(|(d, _)| d)+}++/// [`dir`], and whether it is the app's own downloaded folder (read only when whole, [`crate::lensfun_download::complete`])+/// rather than the user's.+fn source() -> Option<(PathBuf, bool)> {+ match std::env::var_os(ENV).filter(|v| !v.is_empty()) {+ Some(d) => Some((PathBuf::from(d), false)),+ None => HOST_DIR.read().unwrap_or_else(PoisonError::into_inner).clone().map(|d| (d, true)),+ }+}++/// The database in use, read once per folder (and again after [`reload`]); `None` when none is configured, the+/// folder is missing or it holds no lenses.+pub fn database() -> Option<Arc<LensDb>> {+ let (dir, downloaded) = source()?;+ if let Some((d, db)) = LOADED.read().unwrap_or_else(PoisonError::into_inner).as_ref()+ && *d == dir+ {+ return db.clone();+ }+ let generation = generation();+ // the app's own folder without its marker is a download that never finished: not read (the start fetches it)+ let db = if downloaded && !crate::lensfun_download::complete(&dir) { None } else { load(&dir) };+ let mut loaded = LOADED.write().unwrap_or_else(PoisonError::into_inner);+ // a reload while the files were read: they may be the old ones, so they aren't kept (the next call reads again)+ if GENERATION.load(Ordering::Relaxed) == generation {+ *loaded = Some((dir, db.clone()));+ }+ db+}++fn load(dir: &Path) -> Option<Arc<LensDb>> {+ if !dir.is_dir() {+ return None;+ }+ match LensDb::load_dir(dir) {+ Ok((db, errors)) => {+ for (file, e) in errors {+ log::warn!("lens database: skipped {file}: {e}"); }+ (!db.lenses.is_empty()).then(|| Arc::new(db)) }- })- .as_ref()+ Err(e) => {+ log::warn!("{e}");+ None+ }+ }+}++/// The database's name for the lens a photo's file calls `lens` (taken with `camera`, the make and model as the+/// library shows them): "Canon EF 70-200mm f/2.8L IS II USM". `None` without a database or a match.+pub fn profile_name(db: &LensDb, camera: &str, lens: &str) -> Option<String> {+ let cam = camera.split_once(' ').and_then(|(make, model)| db.find_camera(make, model).or_else(|| db.find_camera(make, camera)));+ let p = db.find_lens(None, lens, cam)?;+ let model = p.models.first()?;+ Some(if model.to_lowercase().starts_with(&p.maker.to_lowercase()) || p.maker.is_empty() { model.clone() } else { format!("{} {model}", p.maker) }) } /// The database profile for a photo with metadata `m`, for its `w × h` (unoriented, default-cropped) image,@@ -52,7 +143,7 @@ pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orient let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm); let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h }; let corr = lens.correction(&shot)?;- Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h))+ Some(EmbeddedLens { source: LensSource::Database, ..lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h) }) } /// The crop factor of the recorded frame, which scales the profile's radii: the body's (`camera`), else what the@@ -73,7 +164,7 @@ pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_ crate::files::embedded_lens(raw).or_else(|| { let db = database()?; let (w, h) = raw.developed_size();- lookup(db, &meta(), w as f64, h as f64, raw.orientation)+ lookup(&db, &meta(), w as f64, h as f64, raw.orientation) }) } @@ -107,6 +198,9 @@ mod tests { let db = LensDb::parse(DB).unwrap(); let lens = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Normal).unwrap(); assert!(lens.warp.is_some() && lens.vignette.is_some());+ assert_eq!(lens.source, LensSource::Database, "the Optics panel says where it came from");+ assert_eq!(profile_name(&db, "Testcam TC-1", "18-55mm F3.5-5.6").as_deref(), Some("Testcam 18-55mm f/3.5-5.6"));+ assert_eq!(profile_name(&db, "Testcam TC-1", "70-200mm F4"), None); let rotated = lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Rotate90).unwrap(); assert_eq!(rotated.warp.unwrap().center, lightcraft_geom::Point::new(0.5, 0.5)); // no lens name, no focal length, an unknown lens: nothing@@ -164,7 +258,7 @@ mod tests { }; let at = |crop: f64, w: f64, h: f64| { let shot = Shot { focal: 70.0, aperture: Some(2.8), distance: None, crop_factor: Some(crop), width: w, height: h };- db.lenses[0].correction(&shot).unwrap()+ EmbeddedLens { source: LensSource::Database, ..db.lenses[0].correction(&shot).unwrap() } }; let aps_c = lookup(&db, &m(Some(105.0)), 5616.0, 3744.0, Orientation::Normal).unwrap(); assert_eq!(aps_c, at(1.5, 5616.0, 3744.0));new file mode 100644@@ -0,0 +1,584 @@+//! The lensfun lens database, downloaded by itself on first run (zero setup).+//!+//! lensfun's `data/db` (CC BY-SA 3.0, <https://github.com/lensfun/lensfun>) is data, never bundled with LightCraft and+//! never committed to its repository: the desktop app fetches it at run time into its data folder+//! ([`default_dir`]), with a row in the activity stack, and [`crate::lens_profiles`] reads it from there. The files are+//! pinned to one lensfun commit ([`COMMIT`]) by name, size and SHA-256 ([`FILES`]), so whatever mirror serves them+//! ([`URL_ENV`] lists others, e.g. a copy on LightCraft's own server), what arrives is exactly that commit's data or+//! it is thrown away. A new pin in a later build replaces the folder's files once ([`installed`] compares the pin).+//!+//! The transfer is [`lightcraft_fetch`] (pure-Rust HTTPS, resume, size and hash checks) on a background thread; the+//! files land in a staging folder beside the database folder, the marker is written there last, and only then does the+//! staged folder replace the database folder as a whole ([`publish`]): the previous complete database stays in use+//! until the new one is in place, and comes back if the swap fails. The loader reads the app's folder only when it has+//! the marker ([`complete`]), and [`installed`] also checks every pinned file is there, so a damaged folder is+//! downloaded again. Failure is a message, never a panic.++use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::{Arc, Mutex, PoisonError};++use crate::activity::{Activity, Cancel, TaskHandle, Unit};++/// The lensfun commit the files are pinned to (2026-10-08).+pub const COMMIT: &str = "43f9e001ab66c4fcdd4f400463f13b72f94b2288";+/// Where the files are fetched from by default: lensfun's repository at [`COMMIT`].+pub const DEFAULT_MIRROR: &str = "https://raw.githubusercontent.com/lensfun/lensfun/43f9e001ab66c4fcdd4f400463f13b72f94b2288/data/db";+/// Other places to fetch the same files from, tried first (base URLs separated by commas or spaces).+pub const URL_ENV: &str = "LIGHTCRAFT_LENSFUN_URL";+/// The file written once every database file is in place: the pin it holds says which data the folder has.+pub const MARKER: &str = "SOURCE.txt";+/// The suffix of the folder beside the database folder where files wait until all have arrived.+#[cfg(not(target_arch = "wasm32"))]+const STAGING: &str = "download";+/// The suffix of the folder the previous database is moved to while the new one takes its place.+const PREVIOUS: &str = "previous";+/// Largest database file accepted (the biggest is under 1 MB).+#[cfg(not(target_arch = "wasm32"))]+const MAX_FILE: u64 = 8 << 20;++/// The database files at [`COMMIT`]: name, size in bytes, SHA-256.+pub const FILES: &[(&str, u64, &str)] = &[+ ("6x6.xml", 29550, "ca13c2756b32d728675372695c072517a0e38af5eff2ecd3e17599dee7fc58b3"),+ ("actioncams.xml", 17188, "79a5da0322574b95690464444c9d9ba1da0e1f827da44fd6bff120a0ec10e4b4"),+ ("compact-canon.xml", 247046, "bae47e4b3a0f8852641038ae2dcca5b28ed7965e65ca795fd676f9de857c4670"),+ ("compact-casio.xml", 6889, "ab35433879c4574ef8f97cd4e3d114d7ff8db4e7279da9d502fc9e911b51ee27"),+ ("compact-fujifilm.xml", 67954, "58af9ebef2410c428ae2c5d01003d2247330d47d918823e63522b4d897a997d1"),+ ("compact-kodak.xml", 2091, "377018ccd74f58e966b37a11e5a8689be4268153bebece9f1f2a0dbfa8234845"),+ ("compact-konica-minolta.xml", 13349, "832a7473951ab625e10c48af6cf60f89b57280016d45729e2584cf9e41ad3cc7"),+ ("compact-leica.xml", 9113, "8e4676679377576dc19e7900ce86fc69b0248ea1d4538aa22ee1ee09431a3be3"),+ ("compact-nikon.xml", 40003, "5393bdb8223e13d21b85818e96571e97c035470b49b0452dfcedc2e1ff512d3b"),+ ("compact-olympus.xml", 34454, "7637d469ad54a02ebce9492a9aa9424a2ff1ec07b0398158076f762fdc08389b"),+ ("compact-panasonic.xml", 70621, "f191fc2a5d49fb50c06f4c399ee8c1a83a05f93a0efaecd352ec5c1479b8e60b"),+ ("compact-pentax.xml", 6689, "596fae3edaadcf2d90ae930fc1bec42597ca0b2bd11842e686e1b915f3736390"),+ ("compact-ricoh.xml", 18424, "46df96ebc367d5170cb6b7dfc3742093f0ad946264518fc5488573ba052f29d7"),+ ("compact-samsung.xml", 13875, "36e3a778046d56385c387d48474cd32a89a5da94d99e640b5ce716ce747f898d"),+ ("compact-sigma.xml", 2395, "0ddc265e3e580da05dcd4664d1b9c2464838d74dd02519c826520950c8f1759e"),+ ("compact-sony.xml", 126229, "be146388b4f6f7bf0d73394e20386ce6ccef3559ab39f1268ba3c14aaef03207"),+ ("contax.xml", 1696, "5eba12e6fd4913470afb6fe25fee47a263084b6cec830297273909872f47669a"),+ ("generic.xml", 5416, "cd572d4110981546b2c4cd296c2ff16755b30063fa136d8b06ad2ca13ab4c59f"),+ ("mil-canon.xml", 247994, "2de8e9467a6af6f83cc9da834d3d3b94d1aaf54519f60c68ac6bc02780a77516"),+ ("mil-fujifilm.xml", 210586, "64586336153b948fe2325f6c46679149a4e3c114a70bee4bd1de88721cb4de72"),+ ("mil-hasselblad.xml", 1048, "13e1fa55d36c2ed7d22964c21ce6c21d887bfe4506e840e425da1ce4157c3756"),+ ("mil-leica.xml", 11663, "c691037e2e0743388473c2a0545e340462c412b7e3b57e451f4f9c89dde2388a"),+ ("mil-nikon.xml", 178941, "dcae5ec2e505411a7f00b6b8dd03825e55620403b558b156be15e9ddfbeaf159"),+ ("mil-olympus.xml", 147123, "989a3d88ef07a286efc2755df214792fdf71d60366932a1b369a77ad39cf2abb"),+ ("mil-panasonic.xml", 299264, "eb5f01f33e65721f3900786d5970c7e220dd042aaa56968be33c009a358ff3c5"),+ ("mil-pentax.xml", 1385, "675a94f52794868ad9f1d79e17b7128783c34b3db23e9eac346c5963812a16e5"),+ ("mil-samsung.xml", 13007, "10130e9e125e0882a16fd3033cfed0175e9c14cb7d35e630cdb94736f27e5dc3"),+ ("mil-samyang.xml", 25990, "ae3bf262f12facdc6f81b837cf6d36fdde2a930d2bfb0918d5abaa47fcfea3ab"),+ ("mil-sigma.xml", 126080, "5f39b232efba5c208e5a98d8afe92cb2eee879213322d47827fcc6f0dc3a06f9"),+ ("mil-sony.xml", 274541, "f9f7e5d7461ac58f76eb55dcc5b211f2e9a451402ddb4a9ecb12d1e4dc04f2b8"),+ ("mil-tamron.xml", 106950, "2f0c9c4dfb9b0419ba0fac2e618bc251c2e71e068b6e09a23d7994b0ff62db0a"),+ ("mil-tokina.xml", 1014, "addb27bd82b0d729644b9da1342f92401f63cc5945309ed17f38f4b7af1649e3"),+ ("mil-zeiss.xml", 19171, "ef8a238904fc9ea6fdb6171863dfd70cd94814af19ecd8c31ee000ca1864589d"),+ ("misc.xml", 176039, "2000a347d9dc37d908ea5b56c760de5b2b4ab475857959ae2e901c31339b01bb"),+ ("om-system.xml", 35344, "a53a627e6ba79fbabb0fa7552ce2b6e56e206425d2eaf0e8ccdb6b8c40c20755"),+ ("rf-leica.xml", 6712, "f93636e1980ad308d1873cc3ac078cdc77c0d529d777cd45c5c8d0e4781981e3"),+ ("slr-canon.xml", 780353, "5244e6df8f2522f829c57e2073313ce31a624e9ed9605310fe43613b9d588c99"),+ ("slr-hasselblad.xml", 642, "97602c467699904bf101179ea2fa24f05f3e1879250498a6ba306cd95a3b7a6b"),+ ("slr-konica-minolta.xml", 47170, "5639ca94f121f609247c2c120b65830b3ef7784db6ecfa7e8eb3d29ff5046c1b"),+ ("slr-leica.xml", 432, "c5e74cb511712e99ed60a066e85c842fea58f693c5039c3819485619f5293d0e"),+ ("slr-nikon.xml", 550157, "5e591e8b1032203cce4c822f1da3d49524a78e7d71c594ea5a4533f2e57aacdf"),+ ("slr-olympus.xml", 116602, "f7693b6779edd8ed10d7dc23113669255bfa4987cea7db0e843f7f5f2bbbe615"),+ ("slr-panasonic.xml", 419, "cd79acb49d9ce5f2a4cecc572b3e117f03da380bed2dcbfd52327192d8ce2381"),+ ("slr-pentax.xml", 209552, "2fba773e41a1cfb70f0c85efdab6d78944bf80ddf89bc2035ef6db39da352f29"),+ ("slr-ricoh.xml", 2869, "c835798136a53535fdf4f81312ac9aad0ea3de3ef732c183d8cc4d2f432011d9"),+ ("slr-samsung.xml", 1110, "dc0a29e424ff96e4316ddeb76126fa6f6c73a8836905a3e1f56f0c493826148f"),+ ("slr-samyang.xml", 16811, "fb1585bea91f5f59e1fae8811dd76c92252ee5d52e3647df2aff4695048443b5"),+ ("slr-schneider.xml", 5890, "de607cfe4ba6e7debc61e282ce39b72825c07601572e14af2bc9108ba67faf81"),+ ("slr-sigma.xml", 444640, "f55f41c144fbb72e3c51d2dfef40e918930d37db1b4150bd02bd746406c036d2"),+ ("slr-soligor.xml", 573, "1aba0aab19bce87ea86175b6551cb5306a512eb54be1698b89991b86008f6a04"),+ ("slr-sony.xml", 88865, "e5ca7f532afe23ff380bf2f9e5458f687bed603355d73a254d85c4e692942fdc"),+ ("slr-tamron.xml", 201992, "c62090789cebe6bbb663111c6e97beeb1111852bb735088e29cf8782df4bab45"),+ ("slr-tokina.xml", 91977, "b1213eda81a613bc3976340398c6b8ce8b7dc29423476fc565d5f8e5cf612ecf"),+ ("slr-ussr.xml", 19024, "1fd6acc8a8f40373379bea2899ffd96fc34b22d9b044d0474dabd4c89af44efb"),+ ("slr-vivitar.xml", 2157, "c5c67e3fe38e8fa19044acadc7730047690435f66aac563f55ffe04997a88111"),+ ("slr-zeiss.xml", 14957, "49aa211a41fd5fe391d26af5fa65bdf9428d60863ffff15d3916fa1c520dc101"),+];++/// The app's own copy of the database: `<config dir>/lensfun`.+pub fn default_dir() -> Option<PathBuf> {+ crate::config::config_dir().map(|d| d.join("lensfun"))+}++/// The folder holds the pinned data: [`MARKER`] names [`COMMIT`] and every pinned file is there at its pinned size+/// (a folder whose files went missing is downloaded again).+pub fn installed(dir: &Path) -> bool {+ installed_with(dir, FILES)+}++fn installed_with(dir: &Path, files: &[Pin]) -> bool {+ std::fs::read_to_string(dir.join(MARKER)).is_ok_and(|s| s.contains(COMMIT))+ && files.iter().all(|(name, size, _)| std::fs::metadata(dir.join(name)).is_ok_and(|m| m.is_file() && m.len() == *size))+}++/// The folder holds a whole database, of this pin or an older one: its [`MARKER`], written last, is there. The app's+/// own folder is read only then ([`crate::lens_profiles::database`]).+pub fn complete(dir: &Path) -> bool {+ dir.join(MARKER).is_file()+}++/// `dir` with `suffix` added to its name: a folder beside it (`lensfun` → `lensfun.download`).+fn beside(dir: &Path, suffix: &str) -> PathBuf {+ let mut name = dir.file_name().map(|n| n.to_os_string()).unwrap_or_default();+ name.push(format!(".{suffix}"));+ dir.with_file_name(name)+}++/// Put back the previous database when a swap ([`publish`]) was interrupted between its two moves (the app quit or+/// the machine stopped): `dir` is missing and the previous folder is whole. Best effort; says why it couldn't.+pub fn recover(dir: &Path) {+ let previous = beside(dir, PREVIOUS);+ if !dir.exists()+ && complete(&previous)+ && let Err(e) = std::fs::rename(&previous, dir)+ {+ log::warn!("lens database: could not put back the previous database: {e}");+ }+}++/// The first-start policy, in one place: the folder to fetch the database into without being asked, or `None`. The+/// host must allow it (`auto`, [`crate::Session::lens_auto_download`]); a folder of the user's own+/// (`LIGHTCRAFT_LENSFUN_DIR`) is never replaced; and the app's folder must lack the pinned data.+pub fn fetch_on_start(auto: bool) -> Option<PathBuf> {+ if !auto || std::env::var_os(crate::lens_profiles::ENV).is_some_and(|v| !v.is_empty()) {+ return None;+ }+ let dir = default_dir()?;+ recover(&dir);+ (!installed(&dir)).then_some(dir)+}++/// The mirrors to try, in order: [`URL_ENV`]'s, then lensfun's own ([`DEFAULT_MIRROR`]).+pub fn mirrors() -> Vec<String> {+ #[cfg(not(target_arch = "wasm32"))]+ {+ lightcraft_fetch::mirrors(std::env::var(URL_ENV).ok().as_deref(), None, &[DEFAULT_MIRROR])+ }+ #[cfg(target_arch = "wasm32")]+ {+ Vec::new()+ }+}++/// Where the download stands.+#[derive(Clone, Debug, PartialEq, Eq, Default)]+pub enum State {+ /// Not started this session.+ #[default]+ Idle,+ Running {+ bytes: u64,+ total: u64,+ },+ /// In place and loaded: `files` database files.+ Done {+ files: usize,+ },+ Failed(String),+ Cancelled,+}++struct Shared {+ state: Mutex<State>,+ cancel: Arc<AtomicBool>,+ task: Option<TaskHandle>,+}++impl Shared {+ fn set(&self, s: State) {+ if let (State::Running { bytes, total }, Some(task)) = (&s, &self.task) {+ task.progress(*bytes, *total);+ }+ *self.state.lock().unwrap_or_else(PoisonError::into_inner) = s;+ }++ fn get(&self) -> State {+ self.state.lock().unwrap_or_else(PoisonError::into_inner).clone()+ }+}++/// The session's lens database download.+#[derive(Default)]+pub struct Download {+ job: Option<Arc<Shared>>,+}++/// One pinned file: name, size in bytes, SHA-256.+pub type Pin = (&'static str, u64, &'static str);++impl Download {+ pub fn state(&self) -> State {+ self.job.as_ref().map_or(State::Idle, |j| j.get())+ }++ pub fn running(&self) -> bool {+ matches!(self.state(), State::Running { .. })+ }++ /// Fetch `files` from `mirrors` into `dir` on a background thread, shown in the activity stack until it ends+ /// (its ✕ stops it). Started once at a time; when it ends the database is read again+ /// ([`crate::lens_profiles::replaced`]).+ pub fn start(&mut self, dir: &Path, mirrors: Vec<String>, files: &'static [Pin], activity: &Activity) -> Result<(), String> {+ if self.running() {+ return Err("the lens database is already downloading".into());+ }+ if mirrors.is_empty() {+ return Err("no download location is configured for the lens database".into());+ }+ let cancel = Arc::new(AtomicBool::new(false));+ let task = activity.start("download", "Downloading the lens database", Cancel::Flag(cancel.clone()));+ task.set_unit(Unit::Bytes);+ task.detail("lensfun (CC BY-SA 3.0)");+ let total = files.iter().map(|f| f.1).fold(0u64, u64::saturating_add);+ task.progress(0, total);+ let shared = Arc::new(Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel, task: Some(task.handle()) });+ let worker = shared.clone();+ let dir = dir.to_path_buf();+ std::thread::Builder::new()+ .name("lens-database-download".into())+ .spawn(move || {+ // the row goes when the thread ends, however it ends+ let _task = task;+ let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&dir, &mirrors, files, &worker)));+ if outcome.is_err() {+ worker.set(State::Failed("the lens database download stopped unexpectedly".into()));+ }+ })+ .map_err(|e| format!("could not start the lens database download: {e}"))?;+ self.job = Some(shared);+ Ok(())+ }+}++impl Drop for Download {+ /// Quitting while it runs stops it (the staged files stay for the next try to resume).+ fn drop(&mut self) {+ if let Some(j) = &self.job {+ j.cancel.store(true, Ordering::Relaxed);+ }+ }+}++/// The whole job: fetch and check every file into the staging folder, then move them into `dir`, write the marker+/// and read the database again. Always ends in a final [`State`].+fn run(dir: &Path, mirrors: &[String], files: &[Pin], shared: &Shared) {+ match fetch_all(dir, mirrors, files, shared) {+ Ok(n) => {+ crate::lens_profiles::replaced();+ shared.set(State::Done { files: n });+ }+ Err(e) if shared.cancel.load(Ordering::Relaxed) => {+ log::info!("lens database download stopped: {e}");+ shared.set(State::Cancelled);+ }+ Err(e) => {+ log::warn!("lens database download: {e}");+ shared.set(State::Failed(e));+ }+ }+}++#[cfg(not(target_arch = "wasm32"))]+fn fetch_all(dir: &Path, mirrors: &[String], files: &[Pin], shared: &Shared) -> Result<usize, String> {+ recover(dir);+ let staging = beside(dir, STAGING);+ std::fs::create_dir_all(&staging).map_err(|e| format!("could not create {}: {e}", staging.display()))?;+ let specs: Vec<lightcraft_fetch::FileSpec<'_>> =+ files.iter().map(|(name, size, sha)| lightcraft_fetch::FileSpec { name, size: Some(*size), sha256: Some(sha), max: MAX_FILE }).collect();+ let total = files.iter().map(|f| f.1).fold(0u64, u64::saturating_add);+ lightcraft_fetch::download(&specs, mirrors, &staging, &lightcraft_fetch::Options::default(), &shared.cancel, &mut |p| {+ shared.set(State::Running { bytes: p.done.min(total), total });+ })+ .map_err(|e| match e {+ lightcraft_fetch::DownloadError::AllMirrorsFailed { errors, .. } => {+ let detail: String = errors.join("; ").chars().take(300).collect();+ format!("could not download the lens database ({detail}): check the internet connection; LightCraft tries again next time it starts")+ }+ other => other.to_string(),+ })?;+ // every file is here and checked. The staged folder becomes the database: first without database files another+ // pin left in it (a resumed older attempt), then with the marker, written last, then in place of the old folder+ // as a whole+ remove_stale(&staging, files, |p| std::fs::remove_file(p))?;+ let readme = format!(+ "lensfun lens database (data/db), commit {COMMIT}+from https://github.com/lensfun/lensfun, downloaded by LightCraft.+ Licence: Creative Commons Attribution-ShareAlike 3.0 (https://creativecommons.org/licenses/by-sa/3.0/), by the lensfun contributors.+ Data only, read by LightCraft at run time; not part of LightCraft.+"+ );+ std::fs::write(staging.join(MARKER), readme).map_err(|e| format!("could not write {}: {e}", staging.join(MARKER).display()))?;+ publish(&staging, dir, |from, to| std::fs::rename(from, to))?;+ Ok(files.len())+}++/// Replace the database folder `dir` with the complete `staged` one, moving with `rename`: the old folder is moved+/// aside, the staged one takes its place, and the old one is removed. When the second move fails the old folder is+/// put back, so `dir` is always either the previous complete database or the new one, never a mix.+#[cfg(not(target_arch = "wasm32"))]+fn publish(staged: &Path, dir: &Path, rename: impl Fn(&Path, &Path) -> std::io::Result<()>) -> Result<(), String> {+ let previous = beside(dir, PREVIOUS);+ // left by a swap that ended after its second move+ if previous.exists() {+ std::fs::remove_dir_all(&previous).map_err(|e| format!("could not remove {}: {e}", previous.display()))?;+ }+ let had = dir.exists();+ if had {+ rename(dir, &previous).map_err(|e| format!("could not move the old lens database aside ({}): {e}", dir.display()))?;+ }+ if let Err(e) = rename(staged, dir) {+ if had && let Err(back) = rename(&previous, dir) {+ // (the next start puts it back: [`recover`])+ log::warn!("lens database: could not put back the previous database yet: {back}");+ }+ return Err(format!("could not move the new lens database into {}: {e}", dir.display()));+ }+ if had && let Err(e) = std::fs::remove_dir_all(&previous) {+ log::warn!("lens database: could not remove the previous database {}: {e}", previous.display());+ }+ Ok(())+}++/// Remove the XML files in `dir` that are not among `files` (nothing else in the folder is touched) with `remove`: an+/// error names the first that could not be removed, or why the folder could not be listed.+#[cfg(not(target_arch = "wasm32"))]+fn remove_stale(dir: &Path, files: &[Pin], remove: impl Fn(&Path) -> std::io::Result<()>) -> Result<(), String> {+ // a folder that can't be listed (or an entry that can't be read) may still hold an old file: an error, not success+ let listing = |e: std::io::Error| format!("could not list the lens database folder {}: {e}", dir.display());+ for entry in std::fs::read_dir(dir).map_err(listing)? {+ let entry = entry.map_err(listing)?;+ let name = entry.file_name();+ let name = name.to_string_lossy();+ let xml = std::path::Path::new(&*name).extension().is_some_and(|e| e.eq_ignore_ascii_case("xml"));+ if xml+ && entry.path().is_file()+ && !files.iter().any(|(pinned, _, _)| *pinned == name)+ && let Err(e) = remove(&entry.path())+ {+ return Err(format!("could not remove the old lens database file {name}: {e}"));+ }+ }+ Ok(())+}++#[cfg(target_arch = "wasm32")]+fn fetch_all(_: &Path, _: &[String], _: &[Pin], _: &Shared) -> Result<usize, String> {+ Err("the web build downloads no lens database".into())+}++#[cfg(all(test, not(target_arch = "wasm32")))]+mod tests {+ use super::*;+ use std::io::{BufRead, BufReader, Write};+ use std::net::TcpListener;+ use std::time::Duration;++ fn scratch(name: &str) -> PathBuf {+ let d = std::env::temp_dir().join(format!("lc-lensfun-{name}-{}", std::process::id()));+ for leftover in [d.clone(), beside(&d, STAGING), beside(&d, PREVIOUS)] {+ let _ = std::fs::remove_dir_all(leftover);+ }+ std::fs::create_dir_all(&d).unwrap();+ d+ }++ /// A local server answering `GET /db/<name>` with that file's body (404 otherwise).+ fn serve(files: Vec<(&'static str, Vec<u8>)>) -> String {+ let l = TcpListener::bind("127.0.0.1:0").unwrap();+ let base = format!("http://{}/db", l.local_addr().unwrap());+ std::thread::spawn(move || {+ for mut s in l.incoming().flatten() {+ let mut r = BufReader::new(s.try_clone().unwrap());+ let mut first = String::new();+ let _ = r.read_line(&mut first);+ loop {+ let mut line = String::new();+ if r.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {+ break;+ }+ }+ let path = first.split_whitespace().nth(1).unwrap_or_default().to_string();+ match files.iter().find(|(n, _)| path == format!("/db/{n}")) {+ Some((_, body)) => {+ let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes());+ let _ = s.write_all(body);+ }+ None => {+ let _ = s.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");+ }+ }+ }+ });+ base+ }++ fn wait(what: &str, done: impl Fn() -> bool) {+ let t = std::time::Instant::now();+ while !done() {+ assert!(t.elapsed() < Duration::from_secs(20), "{what}");+ std::thread::sleep(Duration::from_millis(10));+ }+ }++ const LENS: &str = r#"<lensdatabase><lens><maker>Testcam</maker><model>Testcam 50mm f/1.8</model><mount>T</mount><cropfactor>1</cropfactor>+ <calibration><distortion model="poly3" focal="50" k1="-0.01" /></calibration></lens></lensdatabase>"#;+ const CAMERA: &str =+ r#"<lensdatabase><camera><maker>Testcam</maker><model>TC-1</model><mount>T</mount><cropfactor>1</cropfactor></camera></lensdatabase>"#;++ fn sha(s: &str) -> &'static str {+ Box::leak(lightcraft_faces::hash::sha256_hex(s.as_bytes()).into_boxed_str())+ }++ fn pins() -> &'static [Pin] {+ Box::leak(vec![("lenses.xml", LENS.len() as u64, sha(LENS)), ("cameras.xml", CAMERA.len() as u64, sha(CAMERA))].into_boxed_slice())+ }++ /// Every pinned file lands in the folder with the marker, the activity row shows the download until it ends,+ /// and the database reads.+ #[test]+ fn downloads_checks_and_installs_the_pinned_files() {+ let dir = scratch("ok");+ let base = serve(vec![("lenses.xml", LENS.as_bytes().to_vec()), ("cameras.xml", CAMERA.as_bytes().to_vec())]);+ let activity = Activity::default();+ let mut d = Download::default();+ assert!(!installed_with(&dir, pins()));+ // an older pin's database: replaced as a whole, so a file this pin doesn't list is not read beside the new ones+ std::fs::write(dir.join("dropped.xml"), CAMERA).unwrap();+ std::fs::write(dir.join(MARKER), "older").unwrap();+ // a resumed older attempt left a file this pin doesn't list in the staging folder: not published+ std::fs::create_dir_all(beside(&dir, STAGING)).unwrap();+ std::fs::write(beside(&dir, STAGING).join("old-staged.xml"), CAMERA).unwrap();+ d.start(&dir, vec![base], pins(), &activity).unwrap();+ let rows = activity.list();+ assert!(rows.iter().any(|r| r.kind == "download" && r.label == "Downloading the lens database" && r.cancellable), "{rows:?}");+ wait("the download never ended", || !d.running());+ assert_eq!(d.state(), State::Done { files: 2 });+ assert!(installed_with(&dir, pins()), "the marker names the pinned commit and every file is there");+ assert!(!beside(&dir, STAGING).exists() && !beside(&dir, PREVIOUS).exists(), "no staging or previous folder is left");+ let (db, errors) = lightcraft_lensdb::LensDb::load_dir(&dir).unwrap();+ assert!(errors.is_empty() && db.lenses.len() == 1 && db.cameras.len() == 1);+ assert!(!dir.join("dropped.xml").exists() && !dir.join("old-staged.xml").exists(), "only this pin's files");+ wait("the row stayed", || activity.list().is_empty());+ let _ = std::fs::remove_dir_all(&dir);+ }++ /// A file that is not the pinned one (another commit, a hostile mirror) is refused and nothing is installed:+ /// the previous complete database stays as it was.+ #[test]+ fn a_file_that_is_not_the_pinned_one_installs_nothing() {+ let dir = scratch("bad");+ std::fs::write(dir.join("cameras.xml"), "previous").unwrap();+ std::fs::write(dir.join(MARKER), "older").unwrap();+ let base = serve(vec![("lenses.xml", LENS.replace("0.01", "0.02").into_bytes()), ("cameras.xml", CAMERA.as_bytes().to_vec())]);+ let mut d = Download::default();+ d.start(&dir, vec![base], pins(), &Activity::default()).unwrap();+ wait("the download never ended", || !d.running());+ assert!(matches!(d.state(), State::Failed(_)), "{:?}", d.state());+ assert!(!installed_with(&dir, pins()) && complete(&dir), "the previous database is still whole");+ assert_eq!(std::fs::read_to_string(dir.join("cameras.xml")).unwrap(), "previous", "nothing moved into place");+ // no mirror at all is an error, not a thread+ assert!(Download::default().start(&dir, Vec::new(), pins(), &Activity::default()).is_err());+ let _ = std::fs::remove_dir_all(beside(&dir, STAGING));+ let _ = std::fs::remove_dir_all(&dir);+ }++ /// The new database replaces the old folder as a whole or not at all: when moving it into place fails, the+ /// previous database is put back (and when even that fails, the next start puts it back).+ #[test]+ fn a_failed_swap_keeps_the_previous_database() {+ let dir = scratch("swap");+ std::fs::write(dir.join("lenses.xml"), "old").unwrap();+ std::fs::write(dir.join(MARKER), "older").unwrap();+ let staged = beside(&dir, STAGING);+ std::fs::create_dir_all(&staged).unwrap();+ std::fs::write(staged.join("lenses.xml"), "new").unwrap();+ std::fs::write(staged.join(MARKER), COMMIT).unwrap();+ let read = |d: &Path| std::fs::read_to_string(d.join("lenses.xml")).unwrap();+ // the staged folder can't be moved into place: the old one comes back+ let calls = std::cell::Cell::new(0);+ let second_fails = |from: &Path, to: &Path| {+ calls.set(calls.get() + 1);+ if calls.get() == 2 { Err(std::io::Error::other("in use")) } else { std::fs::rename(from, to) }+ };+ let err = publish(&staged, &dir, second_fails).unwrap_err();+ assert!(err.contains("in use"), "{err}");+ assert_eq!(read(&dir), "old", "the previous database is in place");+ assert!(complete(&dir) && !beside(&dir, PREVIOUS).exists());+ // neither move back works either (the app stops here): the next start puts the previous database back+ let calls = std::cell::Cell::new(0);+ let all_but_first_fail = |from: &Path, to: &Path| {+ calls.set(calls.get() + 1);+ if calls.get() == 1 { std::fs::rename(from, to) } else { Err(std::io::Error::other("in use")) }+ };+ assert!(publish(&staged, &dir, all_but_first_fail).is_err());+ assert!(!dir.exists() && complete(&beside(&dir, PREVIOUS)));+ recover(&dir);+ assert_eq!(read(&dir), "old");+ // and a swap that works leaves only the new database+ publish(&staged, &dir, |from, to| std::fs::rename(from, to)).unwrap();+ assert_eq!(read(&dir), "new");+ assert!(!staged.exists() && !beside(&dir, PREVIOUS).exists());+ let _ = std::fs::remove_dir_all(&dir);+ }++ /// A folder whose marker is there but whose database files went missing (or were cut short) is not installed,+ /// so the start downloads it again.+ #[test]+ fn installed_needs_every_pinned_file() {+ let dir = scratch("installed");+ std::fs::write(dir.join(MARKER), format!("commit {COMMIT}")).unwrap();+ assert!(complete(&dir) && !installed_with(&dir, pins()), "the marker alone is not a database");+ std::fs::write(dir.join("lenses.xml"), LENS).unwrap();+ std::fs::write(dir.join("cameras.xml"), CAMERA).unwrap();+ assert!(installed_with(&dir, pins()));+ std::fs::write(dir.join("cameras.xml"), &CAMERA[..10]).unwrap();+ assert!(!installed_with(&dir, pins()), "a file cut short");+ std::fs::remove_file(dir.join("cameras.xml")).unwrap();+ assert!(!installed_with(&dir, pins()), "a file gone");+ std::fs::write(dir.join(MARKER), "an older commit").unwrap();+ std::fs::write(dir.join("cameras.xml"), CAMERA).unwrap();+ assert!(!installed_with(&dir, pins()) && complete(&dir), "an older pin: whole, but replaced");+ let _ = std::fs::remove_dir_all(&dir);+ }++ /// An old database file that can't be removed is an error (so no marker is written and the next start tries+ /// again); the pinned files and other files are left alone.+ #[test]+ fn an_old_file_that_cannot_be_removed_is_an_error() {+ let dir = scratch("stale");+ for name in ["lenses.xml", "old-lenses.xml", "notes.txt"] {+ std::fs::write(dir.join(name), "x").unwrap();+ }+ let failing = |_: &Path| Err(std::io::Error::other("in use"));+ let err = remove_stale(&dir, pins(), failing).unwrap_err();+ assert!(err.contains("old-lenses.xml") && err.contains("in use"), "{err}");+ remove_stale(&dir, pins(), |p| std::fs::remove_file(p)).unwrap();+ assert!(!dir.join("old-lenses.xml").exists());+ assert!(dir.join("lenses.xml").exists() && dir.join("notes.txt").exists());+ // a folder that can't be listed is an error too, not "nothing stale"+ let not_a_folder = dir.join("notes.txt");+ let err = remove_stale(¬_a_folder, pins(), |p| std::fs::remove_file(p)).unwrap_err();+ assert!(err.contains("could not list"), "{err}");+ let err = remove_stale(&dir.join("missing"), pins(), |p| std::fs::remove_file(p)).unwrap_err();+ assert!(err.contains("could not list"), "{err}");+ let _ = std::fs::remove_dir_all(&dir);+ }++ /// The pinned list is lensfun's whole `data/db` at the commit: unique XML names, sane sizes, hex hashes.+ #[test]+ fn the_pins_are_well_formed() {+ assert_eq!(FILES.len(), 56);+ let mut names: Vec<&str> = FILES.iter().map(|f| f.0).collect();+ names.sort_unstable();+ names.dedup();+ assert_eq!(names.len(), FILES.len());+ for (name, size, sha) in FILES {+ assert!(name.ends_with(".xml") && !name.contains(['/', '\\']), "{name}");+ assert!(*size > 0 && *size < MAX_FILE, "{name}");+ assert!(sha.len() == 64 && sha.bytes().all(|b| b.is_ascii_hexdigit()), "{name}");+ }+ assert!(DEFAULT_MIRROR.contains(COMMIT));+ }+}@@ -46,6 +46,7 @@ pub mod guard; pub mod import; mod import_move; pub mod lens_profiles;+pub mod lensfun_download; pub mod library; mod lightroom_archive; pub mod lightroom_catalog;@@ -284,6 +285,11 @@ pub struct Session { pub face_models_dir: Option<std::path::PathBuf>, /// Face model downloads started this session (the staged files wait in `<face_models_dir>/.downloads`). pub face_downloads: face_download::Downloads,+ /// The lens database download ([`lensfun_download`]).+ pub lensfun: lensfun_download::Download,+ /// The host may fetch the lens database by itself when it is missing ([`Session::with_lens_auto_download`];+ /// when: [`lensfun_download::fetch_on_start`]).+ pub lens_auto_download: bool, /// Reverse geocoding: the parsed place names and their download (`metadata.*PlaceNames`). pub place_names: cmd::geocode::PlaceNames, /// The user's own list of models to download (`catalog.json` in the models folder), as last read.@@ -476,6 +482,8 @@ impl Session { denoise: Default::default(), face_models_dir: None, face_downloads: Default::default(),+ lensfun: Default::default(),+ lens_auto_download: false, place_names: Default::default(), face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))]@@ -94,6 +94,20 @@ pub fn content_key(p: &Photo) -> String { } } +/// What a photo's lens profile adds to its render and thumbnail keys: a database profile (0 without one, or with the+/// correction off, where it changes nothing). When a database update gives the photo another profile, its cached+/// renders and thumbnails no longer match. (The file's own correction changes only with the file, which the content+/// key covers, so it adds nothing and existing thumbnails stay valid.)+pub fn lens_key(p: &Photo, settings: &DevelopSettings) -> u64 {+ match &p.embedded_lens {+ Some(l) if settings.optics.lens_profile && l.source == lightcraft_develop::LensSource::Database => {+ let k = Hasher128::new().str("lens").str(&format!("{l:?}")).finish();+ (k.0 as u64) ^ ((k.0 >> 64) as u64) | 1+ }+ _ => 0,+ }+}+ pub fn source_info(p: &Photo) -> SourceInfo { // Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map. // (They are drawn afresh at every size, so they have no original size.)@@ -48,6 +48,7 @@ impl crate::Session { // Temporary "before" settings must not replace the photo's cached develop hash. let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) }; let salt = self.media.denoise_salt(id, &settings);+ let lens = super::quick::lens_key(&p, &settings); let key = settings_hash ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20)@@ -55,7 +56,8 @@ impl crate::Session { ^ (level as u64) << 60 ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17)- ^ salt;+ ^ salt+ ^ lens.rotate_left(29); let cache = thumb_bucket.map(|b| { let mut k = Hasher128::new(); k.str(&content_key).u64(settings_hash).u64(b as u64).u64(RENDER_CACHE_VERSION).u64(crate::camera_profiles::cache_key());@@ -63,6 +65,10 @@ impl crate::Session { if salt != 0 { k.u64(salt); }+ // (likewise a database lens profile: thumbnails of every other photo keep their keys)+ if lens != 0 {+ k.u64(lens);+ } (self.media.rendered.clone(), k.finish()) }); Some(RenderJob {@@ -98,14 +104,18 @@ impl crate::Session { /// Cache key of a variant thumbnail ([`Self::variant_job`]). pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 {- Hasher128::new()- .str(&content_key(p))+ let mut k = Hasher128::new();+ k.str(&content_key(p)) .str("variant") .u64(settings.hash64()) .u64(edge as u64) .u64(RENDER_CACHE_VERSION)- .u64(crate::camera_profiles::cache_key())- .finish()+ .u64(crate::camera_profiles::cache_key());+ let lens = super::quick::lens_key(p, settings);+ if lens != 0 {+ k.u64(lens);+ }+ k.finish() } /// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset@@ -179,14 +179,34 @@ impl DecodedSource { /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) /// with any stored camera tone curve. The original's size comes from `header` either way: the /// decoder only sees the level it decoded.+ ///+ /// A lens profile from the lens database is the catalog's too ([`pinned_lens`]): the decoder looks the lens up in+ /// the database in use when it reads the file, which may be newer than the one the photo's profile came from. pub fn info_or(&self, header: SourceInfo) -> SourceInfo { match &self.info {- Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() },+ Some(info) => {+ SourceInfo { native_long: info.native_long.or(header.native_long), lens: pinned_lens(info.lens, header.lens), ..info.clone() }+ } None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }, } } } +/// The lens profile a render uses: the file's own correction as decoded (`decoded`, it is part of the file), else the+/// catalog's (`catalog`, [`lightcraft_catalog::Photo::embedded_lens`]). A database profile is never taken from the+/// decode, so a cached render, a fresh decode and the Optics panel all use the one profile the photo holds, and a+/// change to it (a database update looked up again, `cmd::lens_refresh`) changes the render keys+/// ([`super::quick::lens_key`]).+pub(crate) fn pinned_lens(+ decoded: Option<lightcraft_develop::EmbeddedLens>,+ catalog: Option<lightcraft_develop::EmbeddedLens>,+) -> Option<lightcraft_develop::EmbeddedLens> {+ match decoded {+ Some(l) if l.source == lightcraft_develop::LensSource::File => Some(l),+ _ => catalog,+ }+}+ #[derive(Clone)] pub enum SourceRef { Loaded(Box<DecodedSource>),@@ -27,6 +27,7 @@ mod ids_params; mod import; mod import_move; mod keyword_list;+mod lens_refresh; mod libops; mod library; mod lightroom_folder;new file mode 100644@@ -0,0 +1,253 @@+//! Lens profiles stay per lens. Presets, Copy / Paste and Sync carry "Enable Lens Corrections" and+//! its amounts, never a profile: each photo keeps the one found for its own lens. And photos imported before the lens+//! database was there get their profile when it arrives (`cmd::lens_refresh`).++use std::sync::Arc;++use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source};+use lightcraft_develop::{EmbeddedLens, EmbeddedWarp, LensSource};+use serde_json::json;++use crate::Session;++fn lens(k: f64) -> EmbeddedLens {+ EmbeddedLens {+ warp: Some(EmbeddedWarp { planes: [[1.0, k, 0.0, 0.0, 0.0, 0.0]; 3], radius: 0.6, ..Default::default() }),+ source: LensSource::Database,+ ..Default::default()+ }+}++fn lens_of(s: &Session, id: PhotoId) -> Option<EmbeddedLens> {+ s.catalog.photo(id).and_then(|p| p.embedded_lens)+}++/// Two demo photos "taken with different lenses".+fn two_lenses() -> (Session, PhotoId, PhotoId) {+ let mut s = Session::with_demo();+ let ids: Vec<PhotoId> = s.visible().iter().copied().take(2).collect();+ let (a, b) = (ids[0], ids[1]);+ s.catalog.apply(Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens(-0.05))) }).unwrap();+ s.catalog.apply(Op::SetEmbeddedLens { id: b, lens: Some(Box::new(lens(0.03))) }).unwrap();+ s.execute("library.select", &json!({"ids": [a.0]})).unwrap();+ s.execute("develop.merge", &json!({"settings": {"optics": {"lens_profile": true}}, "label": "Lens Corrections"})).unwrap();+ s.execute("develop.set", &json!({"values": {"optics.profileDistortion": 60}})).unwrap();+ (s, a, b)+}++fn carried(s: &Session, id: PhotoId) {+ let d = s.develop_of(id).unwrap();+ assert!(d.optics.lens_profile, "the switch travels");+ assert_eq!(d.optics.profile_distortion, 60.0, "the amount travels");+}++#[test]+fn copy_paste_carries_the_switch_and_amounts_never_the_profile() {+ let (mut s, a, b) = two_lenses();+ s.execute("develop.copy", &json!({})).unwrap();+ s.execute("develop.paste", &json!({"ids": [b.0]})).unwrap();+ carried(&s, b);+ assert_eq!(lens_of(&s, b), Some(lens(0.03)), "b keeps its own lens's profile");+ assert_eq!(lens_of(&s, a), Some(lens(-0.05)));+}++#[test]+fn sync_and_presets_keep_each_photos_own_profile() {+ let (mut s, a, b) = two_lenses();+ s.execute("library.select", &json!({"ids": [a.0, b.0], "active": a.0})).unwrap();+ s.execute("develop.sync", &json!({})).unwrap();+ carried(&s, b);+ assert_eq!(lens_of(&s, b), Some(lens(0.03)));+ // a preset made on a's settings, applied to b+ let pid = s.execute("preset.create", &json!({"name": "Optics", "groups": ["optics"]})).unwrap()["id"].as_str().unwrap().to_string();+ s.execute("library.select", &json!({"ids": [b.0]})).unwrap();+ s.execute("develop.merge", &json!({"settings": {"optics": {"lens_profile": false}}, "label": "Lens Corrections"})).unwrap();+ s.execute("preset.apply", &json!({"id": pid})).unwrap();+ carried(&s, b);+ assert_eq!(lens_of(&s, b), Some(lens(0.03)), "a preset never brings another lens's profile");+ assert_eq!(lens_of(&s, a), Some(lens(-0.05)));+}++/// A raw in a library folder, as an older import left it: no lens data.+fn raw(id: u64, lens_name: &str, edited: bool) -> Photo {+ let mut p =+ Photo::new(PhotoId(id), Source::File { path: format!("/photos/{id}.cr3") }, &format!("{id}.cr3"), "CR3", 600, 400, "2026-10-01T00:00:00");+ p.kind = MediaKind::Raw;+ p.meta.camera = "Testcam TC-1".into();+ p.meta.lens = lens_name.into();+ if edited {+ let mut d = (*p.develop).clone();+ d.light.exposure = 0.5;+ p.develop = Arc::new(d);+ p.edited = Some("2026-10-02T00:00:00".into());+ }+ p+}++const DB: &str = r#"<lensdatabase>+ <camera><maker>Testcam</maker><model>TC-1</model><mount>T</mount><cropfactor>1</cropfactor></camera>+ <lens><maker>Testcam</maker><model>Testcam 50mm f/1.8</model><mount>T</mount><cropfactor>1</cropfactor>+ <calibration><distortion model="poly3" focal="50" k1="-0.01" /></calibration></lens></lensdatabase>"#;++/// When the database arrives, the library's raws whose lens it knows get their profile; untouched photos get the+/// correction switched on as import would, edited ones keep their settings; lenses it doesn't know are not probed.+#[test]+fn photos_get_their_profile_when_the_database_arrives() {+ let mut s = Session::new();+ for p in [raw(1, "50mm F1.8", false), raw(2, "50mm F1.8", true), raw(3, "300mm F4", false), raw(4, "", false)] {+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();+ }+ let db = lightcraft_lensdb::LensDb::parse(DB).unwrap();+ let listed = crate::cmd::lens_refresh::missing(&s.catalog, &db, None, false);+ assert_eq!(listed.iter().map(|m| m.id.0).collect::<Vec<_>>(), [1, 2], "only lenses the database knows");+ assert_eq!(crate::cmd::lens_refresh::missing(&s.catalog, &db, Some(&[PhotoId(2)]), false).len(), 1, "or only the photos asked for");+ // what the files give when read again+ let results = listed.into_iter().map(|m| (m, Ok(crate::media::ProbeInfo { embedded_lens: Some(lens(-0.01)), ..Default::default() }))).collect();+ let found = crate::cmd::lens_refresh::apply(&mut s, results).unwrap();+ assert_eq!(found, [PhotoId(1), PhotoId(2)]);+ let p = |id: u64| s.catalog.photo(PhotoId(id)).unwrap().clone();+ assert!(p(1).embedded_lens.is_some() && p(1).develop.optics.lens_profile, "untouched: on, as import would");+ assert!(p(2).embedded_lens.is_some() && !p(2).develop.optics.lens_profile, "edited: its settings stay");+ assert!(p(3).embedded_lens.is_none());+ assert!(s.undo.is_empty(), "bookkeeping, not an undo step");+ // the Optics panel's words+ assert_eq!(crate::cmd::lens_refresh::no_profile_reason(&p(3)).as_deref(), Some("No lens profile found for 300mm F4."));+ assert_eq!(crate::cmd::lens_refresh::no_profile_reason(&p(4)).as_deref(), Some("This file names no lens."));+ assert_eq!(crate::cmd::lens_refresh::no_profile_reason(&p(1)), None);+ // a result read from a file the photo no longer has (moved meanwhile) is dropped, for its virtual copies too+ let mut copy = raw(5, "300mm F4", false);+ copy.copy_of = Some(PhotoId(3));+ s.catalog.apply(Op::AddPhoto { photo: Box::new(copy) }).unwrap();+ let stale = crate::cmd::lens_refresh::Missing { id: PhotoId(3), path: "/elsewhere/3.cr3".into() };+ let ok = crate::media::ProbeInfo { embedded_lens: Some(lens(-0.01)), ..Default::default() };+ assert!(crate::cmd::lens_refresh::apply(&mut s, vec![(stale, Ok(ok))]).unwrap().is_empty());+ assert!(s.catalog.photo(PhotoId(3)).unwrap().embedded_lens.is_none(), "a stale probe changes nothing");+ assert!(s.catalog.photo(PhotoId(5)).unwrap().embedded_lens.is_none(), "nor the copies");+ // without file access the command says so instead of clearing anything+ assert!(s.execute("library.refreshLensProfiles", &json!({})).is_err());+ assert!(s.execute("library.refreshLensProfiles", &json!({"ids": "x"})).is_err());+}++fn with_lens(mut p: Photo, l: Option<EmbeddedLens>, on: bool) -> Photo {+ p.embedded_lens = l;+ let mut d = (*p.develop).clone();+ d.optics.lens_profile = on;+ p.develop = Arc::new(d);+ p+}++/// When the database's files were replaced, the photos whose profile came from the old database are read again+/// (they get the new profile, or lose one the database no longer gives), and so are the raws that named no lens when+/// they were imported. A profile from the file itself is left alone, and none of these are read at an ordinary start.+#[test]+fn a_replaced_database_looks_up_database_profiles_and_unnamed_photos_again() {+ let mut s = Session::new();+ let file_lens = EmbeddedLens { source: LensSource::File, ..lens(-0.04) };+ for p in [+ with_lens(raw(1, "50mm F1.8", false), Some(lens(-0.01)), true),+ with_lens(raw(2, "50mm F1.8", false), Some(file_lens), true),+ raw(3, "", false),+ with_lens(raw(4, "300mm F4", false), Some(lens(-0.02)), true),+ ] {+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();+ }+ let db = lightcraft_lensdb::LensDb::parse(DB).unwrap();+ let ids = |v: Vec<crate::cmd::lens_refresh::Missing>| v.iter().map(|m| m.id.0).collect::<Vec<_>>();+ assert!(crate::cmd::lens_refresh::missing(&s.catalog, &db, None, false).is_empty(), "an ordinary start reads none of them");+ let listed = crate::cmd::lens_refresh::missing(&s.catalog, &db, None, true);+ assert_eq!(ids(listed.clone()), [1, 3, 4], "database profiles and unnamed raws, never the file's own correction");+ // what the files give with the new database: 1 another profile, 3 its lens (now read) and a profile, 4 nothing+ let gives = |id: u64| match id {+ 1 => crate::media::ProbeInfo { embedded_lens: Some(lens(-0.03)), ..Default::default() },+ 3 => {+ let mut info = crate::media::ProbeInfo { embedded_lens: Some(lens(-0.01)), ..Default::default() };+ info.meta.lens = "50mm F1.8".into();+ info+ }+ _ => crate::media::ProbeInfo::default(),+ };+ let results = listed.into_iter().map(|m| (m.clone(), Ok(gives(m.id.0)))).collect();+ let found = crate::cmd::lens_refresh::apply(&mut s, results).unwrap();+ assert_eq!(found, [PhotoId(1), PhotoId(3)]);+ assert_eq!(lens_of(&s, PhotoId(1)), Some(lens(-0.03)), "the new database's profile");+ assert_eq!(lens_of(&s, PhotoId(2)), Some(file_lens), "the file's own correction stays");+ assert_eq!(lens_of(&s, PhotoId(3)), Some(lens(-0.01)));+ assert_eq!(s.catalog.photo(PhotoId(3)).unwrap().meta.lens, "50mm F1.8", "the lens's name, read now");+ assert_eq!(lens_of(&s, PhotoId(4)), None, "a profile the database no longer gives is gone");+ assert!(s.undo.is_empty(), "bookkeeping, not an undo step");+}++/// Every render of a photo uses the one database profile the catalog holds for it: a fresh decode that looked the lens+/// up in a newer database doesn't override it (a cached preview and a fresh render agree), while the file's own+/// correction, part of the file, still comes from the decode.+#[test]+fn a_decode_never_overrides_the_photos_database_profile() {+ use crate::media::DecodedSource;+ let img = Arc::new(lightcraft_raster::Rgb32f::new(4, 4));+ let decoded = |l: EmbeddedLens| DecodedSource::new(img.clone(), Some(lightcraft_pipeline::SourceInfo { lens: Some(l), ..Default::default() }));+ let header = |l: Option<EmbeddedLens>| lightcraft_pipeline::SourceInfo { lens: l, ..Default::default() };+ assert_eq!(decoded(lens(-0.05)).info_or(header(Some(lens(-0.01)))).lens, Some(lens(-0.01)), "the catalog's profile");+ assert_eq!(decoded(lens(-0.05)).info_or(header(None)).lens, None, "none until the photo is given one");+ let file_lens = EmbeddedLens { source: LensSource::File, ..lens(-0.04) };+ assert_eq!(decoded(file_lens).info_or(header(None)).lens, Some(file_lens), "the file's own correction");+}++/// When a photo's database profile changes (a replaced database looked up again), its render key and its thumbnail+/// and variant cache keys change, so no cached render made with the old profile is shown; with the correction off+/// the profile changes nothing, and the keys stay.+#[test]+fn a_new_database_profile_changes_the_render_and_cache_keys() {+ let mut s = Session::new();+ s.catalog.apply(Op::AddPhoto { photo: Box::new(with_lens(raw(1, "50mm F1.8", false), Some(lens(-0.01)), true)) }).unwrap();+ let id = PhotoId(1);+ let keys = |s: &mut Session| {+ let render = s.render_job(id, 800, 600, false, true).unwrap().key;+ let thumb = s.thumb_job(id, 256).unwrap().cache.unwrap().1;+ let p = s.catalog.photo(id).unwrap().clone();+ (render, thumb, Session::variant_key(&p, &p.develop, 128))+ };+ let before = keys(&mut s);+ s.catalog.apply(Op::SetEmbeddedLens { id, lens: Some(Box::new(lens(-0.03))) }).unwrap();+ let after = keys(&mut s);+ assert_ne!(before.0, after.0, "render key");+ assert_ne!(before.1, after.1, "thumbnail cache key");+ assert_ne!(before.2, after.2, "variant cache key");+ // correction off: the profile isn't used, so changing it leaves every key (and the cached thumbnails) as they are+ let off = with_lens((**s.catalog.photo(id).unwrap()).clone(), Some(lens(-0.03)), false);+ s.catalog.apply(Op::SetDevelop { id, settings: off.develop.clone(), label: "Lens Corrections".into(), edited: None }).unwrap();+ let off_before = keys(&mut s);+ s.catalog.apply(Op::SetEmbeddedLens { id, lens: Some(Box::new(lens(-0.05))) }).unwrap();+ assert_eq!(off_before, keys(&mut s));+}++/// `lens.downloadDatabase` selects the folder it downloads into, so the session reads the database once it is in+/// place (a headless session would otherwise download it and go on without it); and the app's own folder is read+/// only when it is whole (its marker, written last, is there).+#[test]+fn the_downloaded_database_is_selected_and_read_only_when_whole() {+ use crate::{lens_profiles, lensfun_download};+ if std::env::var_os(lens_profiles::ENV).is_some_and(|v| !v.is_empty()) {+ lightcraft_testkit::skip!(Input, "LIGHTCRAFT_LENSFUN_DIR names the folder in use");+ return;+ }+ let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());+ let root = std::env::temp_dir().join(format!("lc-lens-select-{}-{unique}", std::process::id()));+ let dir = root.join("lensfun");+ std::fs::create_dir_all(&dir).unwrap();+ std::fs::write(dir.join("lenses.xml"), DB).unwrap();+ let before = lens_profiles::host_dir();+ let mut s = Session::new();+ // nothing listens on port 1: the download fails at once, without the network+ crate::cmd::lens_refresh::start_download(&mut s, &dir, vec!["http://127.0.0.1:1/db".into()], lensfun_download::FILES).unwrap();+ assert_eq!(lens_profiles::dir().as_deref(), Some(dir.as_path()), "the session reads the folder it downloads into");+ assert!(lens_profiles::database().is_none(), "no marker: a download that never finished is not read");+ std::fs::write(dir.join(lensfun_download::MARKER), "whole").unwrap();+ lens_profiles::reload();+ assert_eq!(lens_profiles::database().map(|db| db.lenses.len()), Some(1), "whole: read");+ let t0 = std::time::Instant::now();+ while s.lensfun.running() && t0.elapsed() < std::time::Duration::from_secs(60) {+ std::thread::sleep(std::time::Duration::from_millis(20));+ }+ lens_profiles::use_dir(before);+ let _ = std::fs::remove_dir_all(&root);+}@@ -1104,6 +1104,7 @@ mod tests { }), vignette: None, stages: Default::default(),+ source: Default::default(), }; let mut s = DevelopSettings::default(); s.optics.lens_profile = true;@@ -553,6 +553,7 @@ fn embedded_lens_perspective_edges_match() { }), vignette: Some(EmbeddedVignette { k: [0.4, -0.1, 0.02, 0.0, 0.0], center: Point::new(0.5, 0.5), radius: 0.6 }), stages: Default::default(),+ source: Default::default(), }; let info = SourceInfo { lens: Some(lens), ..raw }; let mut s = DevelopSettings::default();@@ -29,7 +29,9 @@ impl Drop for Headless { impl Default for Headless { fn default() -> Self {- let session = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();+ // the lens database the desktop app downloaded, when it is there (a headless session never fetches it)+ let session =+ Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_lens_database(); Self::new(session.with_default_sam3_model().with_default_slide_templates()) } }@@ -203,6 +205,15 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> mod tests { use super::*; + /// A headless session reads the lens database the desktop app downloaded, as the desktop does (the same raw gets+ /// the same profile everywhere), but never fetches it by itself.+ #[test]+ fn a_headless_session_reads_the_downloaded_lens_database() {+ let h = Headless::default();+ assert_eq!(lightcraft_engine::lens_profiles::host_dir(), lightcraft_engine::lensfun_download::default_dir());+ assert!(!h.session.lens_auto_download, "selecting it is not permission to download it");+ }+ #[test] fn folder_discovery_matches_engine_extensions() { let dir = std::env::temp_dir().join(format!("lc-mcp-discovery-{}", std::process::id()));@@ -375,7 +375,7 @@ pub fn reorient_lens(lens: &EmbeddedLens, o: Orientation, w: f64, h: f64) -> Emb EmbeddedWarpStage { planes, center: map_pt(st.center), radius: st.radius * long_ratio, reciprocal: st.reciprocal } }) });- EmbeddedLens { warp, vignette, stages }+ EmbeddedLens { warp, vignette, stages, source: lens.source } } // ------------------------------------------------------------------------------------------ lateral CA@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "Soubor {name} se nepodařilo přečíst: {e}", "Added {n} photo{}": "Přidáno fotografií: {n}{:.0}", "Where should the Lightroom catalog “{catalog}” go?": "Kam má jít katalog Lightroomu „{catalog}“?",- "Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})"+ "Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})",+ "Profile: {} (lens database)": "Profil: {} (databáze objektivů)",+ "No lens profile found for {}.": "Pro {} nebyl nalezen profil objektivu.",+ "{n} photos got their lens profile": "Profil objektivu získaly fotky: {n}" }@@ -2408,5 +2408,12 @@ "Import dropped files without review": "Importovat přetažené soubory bez kontroly", "An import is running": "Import už běží", "An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí",- "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky"+ "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky",+ "Profile: from the file (the camera's own correction)": "Profil: ze souboru (vlastní korekce fotoaparátu)",+ "Downloading the lens database…": "Stahuje se databáze objektivů…",+ "This file names no lens.": "Tento soubor neuvádí žádný objektiv.",+ "1 photo got its lens profile": "1 fotka získala profil objektivu",+ "Looking up lens profiles": "Hledání profilů objektivů",+ "Downloading the lens database": "Stahování databáze objektivů",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "Databázi objektivů se nepodařilo stáhnout. LightCraft to zkusí znovu při příštím spuštění." }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "{name} konnte nicht gelesen werden: {e}", "Added {n} photo{}": "{n} Foto{} hinzugefügt", "Where should the Lightroom catalog “{catalog}” go?": "Wohin soll der Lightroom-Katalog „{catalog}“?",- "Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})"+ "Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})",+ "Profile: {} (lens database)": "Profil: {} (Objektivdatenbank)",+ "No lens profile found for {}.": "Kein Objektivprofil für {} gefunden.",+ "{n} photos got their lens profile": "{n} Fotos haben ihr Objektivprofil erhalten" }@@ -3440,5 +3440,12 @@ "Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren", "An import is running": "Ein Import läuft", "An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist",- "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst"+ "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst",+ "Profile: from the file (the camera's own correction)": "Profil: aus der Datei (Korrektur der Kamera)",+ "Downloading the lens database…": "Objektivdatenbank wird geladen…",+ "This file names no lens.": "Diese Datei nennt kein Objektiv.",+ "1 photo got its lens profile": "1 Foto hat sein Objektivprofil erhalten",+ "Looking up lens profiles": "Objektivprofile werden gesucht",+ "Downloading the lens database": "Objektivdatenbank wird geladen",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "Die Objektivdatenbank konnte nicht geladen werden. LightCraft versucht es beim nächsten Start erneut." }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "No se pudo leer {name}: {e}", "Added {n} photo{}": "{n} foto{0} añadida{0}", "Where should the Lightroom catalog “{catalog}” go?": "¿Dónde debe ir el catálogo de Lightroom “{catalog}”?",- "Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})"+ "Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})",+ "Profile: {} (lens database)": "Perfil: {} (base de datos de objetivos)",+ "No lens profile found for {}.": "No se encontró un perfil de objetivo para {}.",+ "{n} photos got their lens profile": "{n} fotos recibieron su perfil de objetivo" }@@ -2358,5 +2358,12 @@ "Import dropped files without review": "Importar los archivos soltados sin revisión", "An import is running": "Hay una importación en curso", "An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine",- "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos"+ "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos",+ "Profile: from the file (the camera's own correction)": "Perfil: del archivo (corrección de la propia cámara)",+ "Downloading the lens database…": "Descargando la base de datos de objetivos…",+ "This file names no lens.": "Este archivo no indica ningún objetivo.",+ "1 photo got its lens profile": "1 foto recibió su perfil de objetivo",+ "Looking up lens profiles": "Buscando perfiles de objetivo",+ "Downloading the lens database": "Descargando la base de datos de objetivos",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "No se pudo descargar la base de datos de objetivos. LightCraft lo intentará de nuevo la próxima vez que se inicie." }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "Impossible de lire {name} : {e}", "Added {n} photo{}": "{n} photo{} ajoutée(s)", "Where should the Lightroom catalog “{catalog}” go?": "Où importer le catalogue Lightroom « {catalog} » ?",- "Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})"+ "Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})",+ "Profile: {} (lens database)": "Profil : {} (base de données d'objectifs)",+ "No lens profile found for {}.": "Aucun profil d'objectif trouvé pour {}.",+ "{n} photos got their lens profile": "{n} photos ont reçu leur profil d'objectif" }@@ -2390,5 +2390,12 @@ "Checking recent libraries": "Vérification des bibliothèques récentes", "The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte", "Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages",- "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :"+ "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :",+ "Profile: from the file (the camera's own correction)": "Profil : du fichier (correction de l'appareil)",+ "Downloading the lens database…": "Téléchargement de la base de données d'objectifs…",+ "This file names no lens.": "Ce fichier n'indique aucun objectif.",+ "1 photo got its lens profile": "1 photo a reçu son profil d'objectif",+ "Looking up lens profiles": "Recherche des profils d'objectif",+ "Downloading the lens database": "Téléchargement de la base de données d'objectifs",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "La base de données d’objectifs n’a pas pu être téléchargée. LightCraft réessaiera au prochain démarrage." }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "Impossibile leggere {name}: {e}", "Added {n} photo{}": "Foto aggiunte: {n}{:.0}", "Where should the Lightroom catalog “{catalog}” go?": "Dove importare il catalogo Lightroom “{catalog}”?",- "Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})"+ "Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})",+ "Profile: {} (lens database)": "Profilo: {} (database degli obiettivi)",+ "No lens profile found for {}.": "Nessun profilo obiettivo trovato per {}.",+ "{n} photos got their lens profile": "{n} foto hanno ricevuto il profilo dell'obiettivo" }@@ -2361,5 +2361,12 @@ "Import dropped files without review": "Importa i file rilasciati senza revisione", "An import is running": "Un'importazione è in corso", "An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita",- "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto"+ "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto",+ "Profile: from the file (the camera's own correction)": "Profilo: dal file (correzione della fotocamera)",+ "Downloading the lens database…": "Download del database degli obiettivi…",+ "This file names no lens.": "Questo file non indica alcun obiettivo.",+ "1 photo got its lens profile": "1 foto ha ricevuto il profilo dell'obiettivo",+ "Looking up lens profiles": "Ricerca dei profili degli obiettivi",+ "Downloading the lens database": "Download del database degli obiettivi",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "Impossibile scaricare il database degli obiettivi. LightCraft riproverà al prossimo avvio." }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "{name} を読み取れませんでした: {e}", "Added {n} photo{}": "{n}枚を追加しました{:.0}", "Where should the Lightroom catalog “{catalog}” go?": "Lightroom カタログ「{catalog}」をどこに読み込みますか?",- "Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合(写真{count}枚){:.0}"+ "Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合(写真{count}枚){:.0}",+ "Profile: {} (lens database)": "プロファイル: {}(レンズデータベース)",+ "No lens profile found for {}.": "{} のレンズプロファイルが見つかりません。",+ "{n} photos got their lens profile": "{n} 枚の写真にレンズプロファイルが適用されました" }@@ -2356,5 +2356,12 @@ "Import dropped files without review": "ドロップしたファイルを確認せずに読み込む", "An import is running": "読み込み中です", "An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください",- "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください"+ "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください",+ "Profile: from the file (the camera's own correction)": "プロファイル: ファイル内(カメラ自身の補正)",+ "Downloading the lens database…": "レンズデータベースをダウンロード中…",+ "This file names no lens.": "このファイルにはレンズ名がありません。",+ "1 photo got its lens profile": "1 枚の写真にレンズプロファイルが適用されました",+ "Looking up lens profiles": "レンズプロファイルを検索中",+ "Downloading the lens database": "レンズデータベースをダウンロード中",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "レンズデータベースをダウンロードできませんでした。LightCraft は次回の起動時に再試行します。" }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "ვერ წაიკითხა {name}: {e}", "Added {n} photo{}": "დაემატა ფოტო: {n}{:.0}", "Where should the Lightroom catalog “{catalog}” go?": "სად გადავიტანოთ Lightroom-ის კატალოგი „{catalog}“?",- "Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}"+ "Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}",+ "Profile: {} (lens database)": "პროფილი: {} (ობიექტივების ბაზა)",+ "No lens profile found for {}.": "{}-ისთვის ობიექტივის პროფილი ვერ მოიძებნა.",+ "{n} photos got their lens profile": "ობიექტივის პროფილი მიიღო {n} ფოტომ" }@@ -2734,5 +2734,12 @@ "Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე", "An import is running": "იმპორტი მიმდინარეობს", "An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება",- "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი"+ "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი",+ "Profile: from the file (the camera's own correction)": "პროფილი: ფაილიდან (კამერის საკუთარი კორექცია)",+ "Downloading the lens database…": "ობიექტივების ბაზა იტვირთება…",+ "This file names no lens.": "ამ ფაილში ობიექტივი არ არის მითითებული.",+ "1 photo got its lens profile": "1 ფოტომ მიიღო ობიექტივის პროფილი",+ "Looking up lens profiles": "ობიექტივის პროფილების ძიება",+ "Downloading the lens database": "ობიექტივების ბაზის ჩამოტვირთვა",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "ობიექტივების მონაცემთა ბაზის ჩამოტვირთვა ვერ მოხერხდა. LightCraft კვლავ სცდის შემდეგ გაშვებისას." }@@ -305,5 +305,8 @@ "Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}", "Inside: {names}": "Wewnątrz: {names}", "Where should the Lightroom catalog “{catalog}” go?": "Dokąd zaimportować katalog Lightroom „{catalog}”?",- "Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}"+ "Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}",+ "Profile: {} (lens database)": "Profil: {} (baza obiektywów)",+ "No lens profile found for {}.": "Nie znaleziono profilu obiektywu dla {}.",+ "{n} photos got their lens profile": "Profil obiektywu otrzymały zdjęcia: {n}" }@@ -2283,5 +2283,12 @@ "Import dropped files without review": "Importuj upuszczone pliki bez przeglądu", "An import is running": "Import jest w toku", "An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy",- "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia"+ "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia",+ "Profile: from the file (the camera's own correction)": "Profil: z pliku (korekcja aparatu)",+ "Downloading the lens database…": "Pobieranie bazy obiektywów…",+ "This file names no lens.": "Ten plik nie podaje obiektywu.",+ "1 photo got its lens profile": "1 zdjęcie otrzymało profil obiektywu",+ "Looking up lens profiles": "Wyszukiwanie profili obiektywów",+ "Downloading the lens database": "Pobieranie bazy obiektywów",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "Nie udało się pobrać bazy obiektywów. LightCraft spróbuje ponownie przy następnym uruchomieniu." }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "Não foi possível ler {name}: {e}", "Added {n} photo{}": "{n} foto{0} adicionada{0}", "Where should the Lightroom catalog “{catalog}” go?": "Para onde vai o catálogo do Lightroom “{catalog}”?",- "Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})"+ "Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})",+ "Profile: {} (lens database)": "Perfil: {} (banco de dados de lentes)",+ "No lens profile found for {}.": "Nenhum perfil de lente encontrado para {}.",+ "{n} photos got their lens profile": "{n} fotos receberam o perfil da lente" }@@ -2202,5 +2202,12 @@ "Import dropped files without review": "Importar arquivos soltos sem revisão", "An import is running": "Uma importação está em andamento", "An import is running. Drop photos when it has finished": "Uma importação está em andamento. Solte as fotos quando ela terminar",- "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos"+ "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos",+ "Profile: from the file (the camera's own correction)": "Perfil: do arquivo (correção da própria câmera)",+ "Downloading the lens database…": "Baixando o banco de dados de lentes…",+ "This file names no lens.": "Este arquivo não indica nenhuma lente.",+ "1 photo got its lens profile": "1 foto recebeu o perfil da lente",+ "Looking up lens profiles": "Procurando perfis de lente",+ "Downloading the lens database": "Baixando o banco de dados de lentes",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "Não foi possível baixar o banco de dados de lentes. O LightCraft tentará novamente na próxima vez que for aberto." }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "Не удалось прочитать {name}: {e}", "Added {n} photo{}": "Добавлено фото: {n}{:.0}", "Where should the Lightroom catalog “{catalog}” go?": "Куда импортировать каталог Lightroom «{catalog}»?",- "Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}"+ "Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}",+ "Profile: {} (lens database)": "Профиль: {} (база объективов)",+ "No lens profile found for {}.": "Профиль объектива для {} не найден.",+ "{n} photos got their lens profile": "Профиль объектива получили фото: {n}" }@@ -2349,5 +2349,12 @@ "Import dropped files without review": "Импортировать перетащенные файлы без проверки", "An import is running": "Импорт уже выполняется", "An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится",- "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото"+ "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото",+ "Profile: from the file (the camera's own correction)": "Профиль: из файла (коррекция самой камеры)",+ "Downloading the lens database…": "Загружается база объективов…",+ "This file names no lens.": "В этом файле не указан объектив.",+ "1 photo got its lens profile": "1 фото получило профиль объектива",+ "Looking up lens profiles": "Поиск профилей объективов",+ "Downloading the lens database": "Загрузка базы объективов",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "Не удалось загрузить базу данных объективов. LightCraft попробует снова при следующем запуске." }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "Не вдалося прочитати {name}: {e}", "Added {n} photo{}": "Додано фото: {n}{:.0}", "Where should the Lightroom catalog “{catalog}” go?": "Куди імпортувати каталог Lightroom «{catalog}»?",- "Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}"+ "Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}",+ "Profile: {} (lens database)": "Профіль: {} (база об'єктивів)",+ "No lens profile found for {}.": "Профіль об'єктива для {} не знайдено.",+ "{n} photos got their lens profile": "Профіль об'єктива отримали фото: {n}" }@@ -2354,5 +2354,12 @@ "Import dropped files without review": "Імпортувати перетягнуті файли без перегляду", "An import is running": "Імпорт уже виконується", "An import is running. Drop photos when it has finished": "Імпорт уже виконується. Перетягніть фото, коли він завершиться",- "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото"+ "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото",+ "Profile: from the file (the camera's own correction)": "Профіль: з файлу (корекція самої камери)",+ "Downloading the lens database…": "Завантажується база об'єктивів…",+ "This file names no lens.": "У цьому файлі не вказано об'єктив.",+ "1 photo got its lens profile": "1 фото отримало профіль об'єктива",+ "Looking up lens profiles": "Пошук профілів об'єктивів",+ "Downloading the lens database": "Завантаження бази об'єктивів",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "Не вдалося завантажити базу даних об’єктивів. LightCraft спробує знову під час наступного запуску." }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "无法读取 {name}:{e}", "Added {n} photo{}": "已添加 {n} 张照片{:.0}", "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目录“{catalog}”要导入到哪里?",- "Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”({count} 张照片){:.0}"+ "Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”({count} 张照片){:.0}",+ "Profile: {} (lens database)": "配置文件:{}(镜头数据库)",+ "No lens profile found for {}.": "未找到 {} 的镜头配置文件。",+ "{n} photos got their lens profile": "{n} 张照片获得了镜头配置文件" }@@ -2200,5 +2200,12 @@ "Import dropped files without review": "不经检查直接导入拖入的文件", "An import is running": "正在导入", "An import is running. Drop photos when it has finished": "正在导入。导入完成后再拖放照片",- "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入"+ "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入",+ "Profile: from the file (the camera's own correction)": "配置文件:来自文件(相机自身的校正)",+ "Downloading the lens database…": "正在下载镜头数据库…",+ "This file names no lens.": "此文件未注明镜头。",+ "1 photo got its lens profile": "1 张照片获得了镜头配置文件",+ "Looking up lens profiles": "正在查找镜头配置文件",+ "Downloading the lens database": "正在下载镜头数据库",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "无法下载镜头数据库。LightCraft 会在下次启动时重试。" }@@ -305,5 +305,8 @@ "Could not read {name}: {e}": "無法讀取 {name}:{e}", "Added {n} photo{}": "已加入 {n} 張照片{:.0}", "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目錄「{catalog}」要匯入到哪裡?",- "Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」({count} 張照片){:.0}"+ "Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」({count} 張照片){:.0}",+ "Profile: {} (lens database)": "設定檔:{}(鏡頭資料庫)",+ "No lens profile found for {}.": "找不到 {} 的鏡頭設定檔。",+ "{n} photos got their lens profile": "{n} 張照片取得了鏡頭設定檔" }@@ -2157,5 +2157,12 @@ "Import dropped files without review": "不經檢查直接匯入拖放的檔案", "An import is running": "正在匯入", "An import is running. Drop photos when it has finished": "正在匯入。匯入完成後再拖放照片",- "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入"+ "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入",+ "Profile: from the file (the camera's own correction)": "設定檔:來自檔案(相機本身的校正)",+ "Downloading the lens database…": "正在下載鏡頭資料庫…",+ "This file names no lens.": "此檔案未註明鏡頭。",+ "1 photo got its lens profile": "1 張照片取得了鏡頭設定檔",+ "Looking up lens profiles": "正在查找鏡頭設定檔",+ "Downloading the lens database": "正在下載鏡頭資料庫",+ "The lens database could not be downloaded. LightCraft tries again the next time it starts.": "無法下載鏡頭資料庫。LightCraft 會在下次啟動時重試。" }@@ -66,6 +66,8 @@ impl LightcraftApp { lightroom_import::tick(self, ctx); recheck::tick(self); tasks::poll(self, ctx);+ // after the poll: a lookup that just finished is cleared, so photos queued meanwhile are looked up now+ lens_db::tick(self); updates_ui::tick(self, ctx); self.preview_build_status(ctx); self.save_status(ctx);new file mode 100644@@ -0,0 +1,198 @@+//! The lens database without setup: on first run the app fetches lensfun's database by itself+//! (`lightcraft_engine::lensfun_download`, an activity row while it runs), and whenever the database in use changes+//! (it arrived, a newer pin replaced it) or Enable Lens Corrections is ticked on a photo without a profile, the+//! library's raws that have none are looked up again on a worker thread (`lightcraft_engine::cmd::lens_refresh`);+//! when its files were replaced, so are those whose profile came from the database and those that named no lens.++use lightcraft_engine::cmd::{lens_refresh, recheck};+use lightcraft_engine::{lens_profiles, lensfun_download};++use crate::LightcraftApp;++/// The activity stack's label while files are read for their lens.+pub const LABEL: &str = "Looking up lens profiles";++/// Called every frame.+pub fn tick(app: &mut LightcraftApp) {+ start_download(app);+ refresh(app);+}++/// Once per run, when the start policy says so ([`lensfun_download::fetch_on_start`]: the host allows it and the+/// pinned database is missing or damaged).+fn start_download(app: &mut LightcraftApp) {+ if app.ui.lens_download_checked {+ return;+ }+ app.ui.lens_download_checked = true;+ let Some(dir) = lensfun_download::fetch_on_start(app.session.lens_auto_download) else { return };+ let activity = app.session.activity.clone();+ if let Err(e) = app.session.lensfun.start(&dir, lensfun_download::mirrors(), lensfun_download::FILES, &activity) {+ log::warn!("lens database: {e}");+ }+}++/// After Enable Lens Corrections was ticked on photo `id`: the photos to look up again, those of `id` and the+/// selection that now have corrections on and no profile. With Auto Sync off only `id` was changed, so the other+/// selected photos are left alone unless their corrections were already on.+pub fn to_look_up(session: &lightcraft_engine::Session, id: lightcraft_catalog::PhotoId) -> Vec<lightcraft_catalog::PhotoId> {+ let mut out: Vec<lightcraft_catalog::PhotoId> = Vec::new();+ let mut seen = std::collections::HashSet::new();+ for t in std::iter::once(id).chain(session.selection.ids.iter().copied()) {+ if seen.insert(t) && session.catalog.photo(t).is_some_and(|p| p.embedded_lens.is_none() && p.develop.optics.lens_profile) {+ out.push(t);+ }+ }+ out+}++/// What a lookup's results belong to: the library, the lens database in use and how often its files were replaced.+/// Results for another key are stale.+pub(crate) fn lookup_key(app: &LightcraftApp) -> (u64, u64, u64) {+ (app.session.library_generation(), lens_profiles::generation(), lens_profiles::replacements())+}++/// Whether this lookup also reads the photos that have a database profile or no lens name+/// ([`lens_refresh::missing`]'s `again`): only when the database's files were replaced since the last lookup, not at+/// every start or library switch.+pub(crate) fn look_up_again(last: Option<(u64, u64, u64)>, key: (u64, u64, u64)) -> bool {+ last.map_or(key.2 > 0, |last| last.2 != key.2)+}++/// Look the library's raws up again when the database changed, the library changed, or photos asked for it.+fn refresh(app: &mut LightcraftApp) {+ let key = lookup_key(app);+ let asked = std::mem::take(&mut app.ui.lens_lookup_ids);+ if app.ui.lens_refreshed == Some(key) && asked.is_empty() {+ return;+ }+ // (a lookup already under way finishes first; what was asked meanwhile waits for the next frame)+ if app.tasks.is_running(LABEL) {+ app.ui.lens_lookup_ids.extend(asked);+ return;+ }+ let ids = (app.ui.lens_refreshed == Some(key)).then_some(asked);+ let again = ids.is_none() && look_up_again(app.ui.lens_refreshed, key);+ app.ui.lens_refreshed = Some(key);+ let Some(probe) = recheck::file_probe(&app.session) else { return };+ let Some(db) = lens_profiles::database() else { return };+ let listed = lens_refresh::missing(&app.session.catalog, &db, ids.as_deref(), again);+ if listed.is_empty() {+ return;+ }+ let work = move || lens_refresh::probe(&probe, listed);+ let done = move |app: &mut LightcraftApp, ctx: &egui::Context, results| {+ // another library, or a database reloaded while the files were read (the probe may have used either): the+ // next frame looks them up again with the database in use now (asked for here: an idle window draws none)+ if lookup_key(app) != key {+ ctx.request_repaint();+ return;+ }+ match lens_refresh::apply(&mut app.session, results) {+ Ok(found) if found.len() == 1 => app.toast(ctx, crate::i18n::tr("1 photo got its lens profile")),+ Ok(found) if found.len() > 1 => app.toast(ctx, crate::i18n::tr_format!("{n} photos got their lens profile", n = found.len())),+ Ok(_) => {}+ Err(e) => log::warn!("lens profile lookup: {e}"),+ }+ };+ if let Err(e) = crate::tasks::spawn(app, LABEL, Some("lensLookup"), work, done) {+ log::warn!("{e}");+ }+}++/// The database's name for a lens, remembered per lens and database (the panel asks every frame; a search scores+/// every lens in the database).+fn database_name(camera: &str, lens: &str) -> Option<String> {+ type Key = (String, String, u64);+ thread_local! {+ static LAST: std::cell::RefCell<Option<(Key, Option<String>)>> = const { std::cell::RefCell::new(None) };+ }+ let key = (camera.to_string(), lens.to_string(), lens_profiles::generation());+ LAST.with(|last| {+ if let Some((k, name)) = last.borrow().as_ref()+ && *k == key+ {+ return name.clone();+ }+ let name = lens_profiles::database().and_then(|db| lens_profiles::profile_name(&db, camera, lens));+ *last.borrow_mut() = Some((key, name.clone()));+ name+ })+}++/// What the Optics panel says about photo `p`'s lens profile: which one is loaded and from where, or why there is+/// none.+pub fn readout(app: &LightcraftApp, p: &lightcraft_catalog::Photo) -> String {+ use lightcraft_develop::LensSource;+ match p.embedded_lens.map(|l| l.source) {+ Some(LensSource::File) => crate::i18n::tr("Profile: from the file (the camera's own correction)").to_string(),+ Some(LensSource::Database) => {+ let name = database_name(&p.meta.camera, &p.meta.lens).unwrap_or_else(|| p.meta.lens.trim().to_string());+ crate::i18n::tr_format!("Profile: {} (lens database)", name)+ }+ None if app.session.lensfun.running() => crate::i18n::tr("Downloading the lens database…").to_string(),+ None if matches!(app.session.lensfun.state(), lensfun_download::State::Failed(_)) && lens_profiles::database().is_none() => {+ crate::i18n::tr("The lens database could not be downloaded. LightCraft tries again the next time it starts.").to_string()+ }+ None => match lens_refresh::no_profile_reason(p) {+ Some(r) if p.meta.lens.trim().is_empty() => crate::i18n::tr(&r).to_string(),+ Some(_) => crate::i18n::tr_format!("No lens profile found for {}.", p.meta.lens.trim()),+ None => String::new(),+ },+ }+}++#[cfg(test)]+mod tests {+ use crate::{LightcraftApp, Services};+ use lightcraft_develop::{EmbeddedLens, LensSource};++ /// The Optics panel says which profile is loaded and from where, or why there is none.+ #[test]+ fn the_optics_readout_names_the_profile_or_the_lens_without_one() {+ let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });+ let id = app.session.active().expect("a photo");+ let mut p = (**app.session.catalog.photo(id).expect("the photo")).clone();+ p.meta.lens = "EF35mm f/1.4L II USM".into();+ p.embedded_lens = None;+ assert_eq!(super::readout(&app, &p), "No lens profile found for EF35mm f/1.4L II USM.");+ p.meta.lens.clear();+ assert_eq!(super::readout(&app, &p), "This file names no lens.");+ p.embedded_lens = Some(EmbeddedLens::default());+ assert_eq!(super::readout(&app, &p), "Profile: from the file (the camera's own correction)");+ p.meta.lens = "EF70-200mm f/2.8L IS II USM".into();+ p.embedded_lens = Some(EmbeddedLens { source: LensSource::Database, ..Default::default() });+ // the database's name for it, or the file's own without a database (LIGHTCRAFT_LENSFUN_DIR may be set)+ let r = super::readout(&app, &p);+ assert!(r.starts_with("Profile: ") && r.contains("70-200mm f/2.8L IS II USM") && r.ends_with(" (lens database)"), "{r}");+ }++ /// A first-run download that failed says so, instead of "no profile" for every lens.+ #[test]+ fn a_failed_download_is_named_in_the_optics_readout() {+ let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });+ let id = app.session.active().expect("a photo");+ let mut p = (**app.session.catalog.photo(id).expect("the photo")).clone();+ p.meta.lens = "EF35mm f/1.4L II USM".into();+ p.embedded_lens = None;+ let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());+ let root = std::env::temp_dir().join(format!("lc-lens-db-failed-{}-{unique}", std::process::id()));+ // (the download stages its files beside the database folder: both live in this test's own folder)+ let dir = root.join("lensfun");+ // nothing listens on port 1: the download fails at once, without the network+ let activity = app.session.activity.clone();+ app.session+ .lensfun+ .start(&dir, vec!["http://127.0.0.1:1/db".into()], lightcraft_engine::lensfun_download::FILES, &activity)+ .expect("started");+ let t0 = std::time::Instant::now();+ while app.session.lensfun.running() && t0.elapsed() < std::time::Duration::from_secs(60) {+ std::thread::sleep(std::time::Duration::from_millis(20));+ }+ assert!(matches!(app.session.lensfun.state(), lightcraft_engine::lensfun_download::State::Failed(_)), "{:?}", app.session.lensfun.state());+ if lightcraft_engine::lens_profiles::database().is_none() {+ assert_eq!(super::readout(&app, &p), "The lens database could not be downloaded. LightCraft tries again the next time it starts.");+ }+ let _ = std::fs::remove_dir_all(&root);+ }+}@@ -19,6 +19,7 @@ mod frame_rate; pub mod headless; pub mod i18n; pub mod icons;+pub mod lens_db; pub mod lightroom_import; pub mod links; pub mod menus;@@ -333,12 +333,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } let mut lp = d.optics.lens_profile; if ui.checkbox(&mut lp, crate::i18n::tr("Enable Lens Corrections")).changed() {- let _ = app.run("develop.merge", json!({"settings": {"optics": {"lens_profile": lp}}, "label": "Lens Corrections"}));+ let ok = app.run("develop.merge", json!({"settings": {"optics": {"lens_profile": lp}}, "label": "Lens Corrections"})).is_ok();+ // ticked on photos without a profile: look their lenses up again (a database that arrived+ // since they were imported may know them now)+ if ok && lp {+ let without = crate::lens_db::to_look_up(&app.session, id);+ app.ui.lens_lookup_ids.extend(without);+ } }- if lp && !has_lens {- ui.label(- egui::RichText::new(crate::i18n::tr("No lens data embedded in this file.")).size(11.0).color(Tokens::get(ui.ctx()).text_dim),- );+ if lp && let Some(p) = app.session.catalog.photo(id).cloned() {+ let text = crate::lens_db::readout(app, &p);+ if !text.is_empty() {+ let r = ui.label(egui::RichText::new(text).size(11.0).color(Tokens::get(ui.ctx()).text_dim));+ crate::widgets::register(ui, "optics:lensProfile", r.rect);+ } } }); if d.optics.lens_profile {@@ -345,6 +345,16 @@ pub struct UiState { /// (`crate::recheck`). #[serde(skip)] pub rechecked_library: Option<u64>,+ /// The first-run lens database download was considered this run (`crate::lens_db`).+ #[serde(skip)]+ pub lens_download_checked: bool,+ /// The library, lens database generation and database replacement count whose raws were last looked up for a+ /// lens profile (`crate::lens_db::lookup_key`).+ #[serde(skip)]+ pub lens_refreshed: Option<(u64, u64, u64)>,+ /// Photos to look up for a lens profile on the next frame (Enable Lens Corrections ticked without one).+ #[serde(skip)]+ pub lens_lookup_ids: Vec<lightcraft_catalog::PhotoId>, #[serde(skip)] pub status: String, #[serde(skip)]@@ -490,6 +500,9 @@ impl Default for UiState { #[cfg(test)] find_missing_test_delay: None, rechecked_library: None,+ lens_download_checked: false,+ lens_refreshed: None,+ lens_lookup_ids: Vec::new(), status: String::new(), dialog: None, print: Default::default(),@@ -41,6 +41,7 @@ mod keywording; mod labels; mod launch_library; mod layout_image;+mod lens_lookup; mod library_problem; mod lifecycle_037; mod lightroom_destination;new file mode 100644@@ -0,0 +1,50 @@+//! Enable Lens Corrections looks up again only the photos it switched on (and selected photos that already had+//! corrections on): with Auto Sync off the other selected photos are left alone.++use lightcraft_engine::Session;+use serde_json::json;++#[test]+fn ticking_lens_corrections_looks_up_only_the_photos_it_changed() {+ let mut s = Session::with_demo();+ s.execute("library.selectAll", &json!({})).unwrap();+ let active = s.selection.active.unwrap();+ assert!(s.selection.ids.len() > 2);+ let lp = |s: &Session| s.selection.ids.iter().filter(|id| s.catalog.photo(**id).unwrap().develop.optics.lens_profile).count();+ assert_eq!(lp(&s), 0);+ // Auto Sync off: only the active photo is changed, so only it is looked up+ s.execute("develop.merge", &json!({"settings": {"optics": {"lens_profile": true}}})).unwrap();+ assert_eq!(crate::lens_db::to_look_up(&s, active), vec![active]);+ // Auto Sync on: every selected photo now has corrections on, and each is looked up once+ let mut s = Session::with_demo();+ s.execute("library.selectAll", &json!({})).unwrap();+ let active = s.selection.active.unwrap();+ s.auto_sync = true;+ s.execute("develop.merge", &json!({"settings": {"optics": {"lens_profile": true}}})).unwrap();+ assert_eq!(lp(&s), s.selection.ids.len());+ let ids = crate::lens_db::to_look_up(&s, active);+ assert_eq!(ids.len(), s.selection.ids.len());+ assert_eq!(ids.first(), Some(&active));+}++/// A lookup's results are dropped when the lens database was reloaded while the files were read (the probe may have+/// used the old one); the next frame looks the photos up again.+#[test]+fn a_database_reloaded_during_a_lookup_makes_its_results_stale() {+ let app = crate::LightcraftApp::new(Session::with_demo(), crate::Services { png: None, ..Default::default() });+ let key = crate::lens_db::lookup_key(&app);+ assert_eq!(crate::lens_db::lookup_key(&app), key);+ lightcraft_engine::lens_profiles::reload();+ assert_ne!(crate::lens_db::lookup_key(&app), key);+}++/// Photos that have a database profile (or named no lens) are read again only when the database's files were+/// replaced, never at every start, library switch or folder selection: a big library isn't read at each launch.+#[test]+fn photos_with_a_profile_are_read_again_only_when_the_database_was_replaced() {+ use crate::lens_db::look_up_again;+ assert!(!look_up_again(None, (1, 1, 0)), "a start with the database already there");+ assert!(!look_up_again(Some((1, 1, 0)), (2, 2, 0)), "another library, or the folder selected again");+ assert!(look_up_again(Some((1, 1, 0)), (1, 2, 1)), "a download replaced it");+ assert!(look_up_again(None, (1, 2, 1)), "replaced before the first lookup");+}@@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (gap 8: lensfun's lens database downloaded on first run, looked up for the library; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is@@ -22,7 +22,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) |-| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) |+| 8 B | **Lens profiles: lensfun only, no picker, no own calibration** (LR-EDIT-OPTICS-PROFILE): the desktop app downloads lensfun's database (CC BY-SA 3.0, pinned, never bundled) by itself on first run and looks every raw up by its lens ([lens-profiles.md](lens-profiles.md)), but those profiles are not validated against Lightroom, there is no profile picker (in the file / lens database / own calibration) and no calibration wizard; in-file corrections: DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS or Windows**: Linux x64 runs `cargo xtask ci` on every PR and push to main (`.github/workflows/ci.yml`) and FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (ci, freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU; macOS / Windows jobs: #1148 | regressions in `cfg(target_os)` code reach macOS and Windows users | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 551 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 regression (#702); F828 SRF now discovered and imported as preview-only, sensor decoding still missing, DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) |@@ -84,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Gap 8 shrinks: the desktop app downloads lensfun's lens database (CC BY-SA, pinned) by itself on first run and looks the library's raws up when it arrives; validation, a profile picker and own calibration remain | | 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | | 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged |@@ -1,18 +1,47 @@ # Lens profiles from a lens database Raws that carry no lens correction of their own (DNG `OpcodeList3`, or a camera correction a raw reader converts,-see [sony-lens-corrections.md](sony-lens-corrections.md) and [nikon-lens-corrections.md](nikon-lens-corrections.md)) can be corrected from a **lens database in the lensfun-XML format** that you supply. The app does not ship any lens database: lensfun's data is licensed CC-BY-SA 3.0 and-stays a separate download. Adobe LCP profiles are never used.+see [sony-lens-corrections.md](sony-lens-corrections.md) and [nikon-lens-corrections.md](nikon-lens-corrections.md)) are corrected from a **lens database in the lensfun+XML format**. The app does not ship any lens database: lensfun's data is licensed CC BY-SA 3.0 and is downloaded at+run time, never bundled and never committed. Adobe LCP profiles are never used. -## Setup+## Setup: none -1. Get the lensfun database (the `data/db/*.xml` files of <https://github.com/lensfun/lensfun>, or the copy a Linux- distribution installs under `/usr/share/lensfun/version_1/`).-2. Point `LIGHTCRAFT_LENSFUN_DIR` at that directory and start the app (or the CLI).+1. On first run the desktop app downloads lensfun's `data/db` (56 XML files, about 5 MB) into its data folder+ (`<config dir>/lensfun`, beside the face and denoise models; see `crates/engine/src/config.rs`), with a row in+ the activity stack (its ✕ stops it; the next start resumes). The files are pinned to one lensfun commit by name,+ size and SHA-256 (`crates/engine/src/lensfun_download.rs`); a file that differs is thrown away. A later build with+ a newer pin replaces them once. The files are staged in `<config dir>/lensfun.download`, `SOURCE.txt` (the commit+ and the licence) is written there last, and the staged folder then replaces the old one as a whole: until it is+ in place the previous complete database is used, and if the swap fails it is put back. The app's folder is read+ only when it has `SOURCE.txt`, and a folder whose pinned files are missing or cut short is downloaded again.+ The CLI (`render`, `merge`, `snapshot`) and the headless MCP server read this folder too, but never start the+ first-run download; `lens.downloadDatabase` fetches it on request from any of them.+2. By default the files come from lensfun's GitHub repository at the pinned commit. `LIGHTCRAFT_LENSFUN_URL` lists+ other places serving the same files (base URLs, tried first), e.g. a copy on LightCraft's own server.+3. `LIGHTCRAFT_LENSFUN_DIR` points at a database of your own instead (lensfun's `data/db`, or a Linux+ distribution's `/usr/share/lensfun/version_1/`); then the first-run download doesn't start, and that folder is read+ even after `lens.downloadDatabase` fetches the app's own copy. -Files are read once per process. Invalid files are skipped with a warning in the log. Without the variable nothing-changes.+When the database arrives (or changes), every library raw without a profile whose lens it knows is read again in the+background and gets its profile; a photo still as imported gets the correction switched on, as import would, and an+edited one keeps its settings. When a download replaced the database's files, the raws whose profile came from the+old database are read again too (they get the new profile, or lose one it no longer gives), and so are raws whose+file named no lens at import. Ticking **Enable Lens Corrections** on a photo without a profile looks it up again+too. `library.refreshLensProfiles` (`all: true` for the wider pass), `lens.downloadDatabase` (which also selects the+downloaded folder) and `lens.databaseStatus` do the same for agents.++A database profile is pinned to the photo: every render (fresh decode, cached preview, thumbnail, export) uses the+profile the catalog holds, never one a decode looked up in a newer database, and the profile is part of the render+and thumbnail cache keys, so a changed profile never shows an old cached render. A file's own correction comes from+the file as before.++The Optics panel says which profile is loaded: "Profile: from the file", "Profile: <lens> (lens database)", or+"No lens profile found for <lens>". A profile is never copied between photos: presets, Copy / Paste, Sync and Auto+Sync carry Enable Lens Corrections and its amounts, and each photo keeps the profile of its own lens+(`crates/engine/src/tests/lens_refresh.rs`).++Files are read once per folder and again after a download. Invalid files are skipped with a warning in the log. ## What is applied @@ -50,5 +79,6 @@ the image centre (lensfun's `<center>` offsets are not read). - Only raws: rendered images (JPEG, HEIF, TIFF) are often already corrected in camera. - The focus distance is not read from maker notes yet: vignetting uses the farthest calibrated distance.-- No manual make / model picker yet; matching is automatic.+- No manual make / model picker and no choice between sources (in the file, lens database, an own calibration)+ yet; matching is automatic, the file's own correction wins. - Not yet validated against real photos and another renderer; the tests use synthetic profiles and grids.@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-EDIT-OPTICS-PROFILE: lensfun downloaded by itself on first run, profiles looked up for the library, the Optics panel names the profile; profiles never travel between lenses; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good@@ -212,7 +212,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | |-| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses, and ILCE-7CR full-frame Bayer 3:2 files with correction Auto or forced Auto (a separate corrected-radius conversion validated against camera JPEGs with FE 24-50mm, FE 16-25mm and FE 50mm lenses); A7CR Off, APS-C, aspect crops and lossless M/S remain uncorrected; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A, ILCE-7RM6 and ILCE-7CR use the separate distortion conversions above; Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); raws that name no lens model (Nikon DSLRs: maker-note lens specification only) look it up by a name built from the specification (`24-70mm f/2.8`) and take only a profile named exactly that; no settings switch or bundled database yet |+| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses, and ILCE-7CR full-frame Bayer 3:2 files with correction Auto or forced Auto (a separate corrected-radius conversion validated against camera JPEGs with FE 24-50mm, FE 16-25mm and FE 50mm lenses); A7CR Off, APS-C, aspect crops and lossless M/S remain uncorrected; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A, ILCE-7RM6 and ILCE-7CR use the separate distortion conversions above; Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); raws that name no lens model (Nikon DSLRs: maker-note lens specification only) look it up by a name built from the specification (`24-70mm f/2.8`) and take only a profile named exactly that; lens database: the desktop app downloads lensfun's `data/db` (CC BY-SA 3.0, pinned by commit, size and SHA-256, never bundled; `cmd:lens.downloadDatabase`, `crates/engine/src/lensfun_download.rs`) on first run with an activity row, and when it arrives every library raw whose lens it knows is looked up again in the background (`cmd:library.refreshLensProfiles`, `crates/engine/src/cmd/lens_refresh.rs`), as is a photo whose Enable Lens Corrections is ticked; the Optics panel names the loaded profile and its source, or says "No lens profile found for <lens>" (`crates/ui-egui/src/lens_db.rs`); presets, Copy / Paste and Sync carry only the switch and amounts, never a profile (`crates/engine/src/tests/lens_refresh.rs`); no source picker or own calibration yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | |@@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | LR-EDIT-OPTICS-PROFILE: lensfun's database downloaded by itself on first run (pinned, CC BY-SA, never bundled), the library looked up when it arrives or Enable Lens Corrections is ticked, the Optics panel names the profile or the lens without one; status unchanged (🟡) | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |