mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 17:31:09 -06:00
Merge upstream/main into feat/lens-blur
main took catalog format versions 10 (`Mask.on_photo`) and 11 (`EmbeddedLens.source`), so Lens Blur moves from 10 to 12, one past main's (journal.rs VERSION and its table row, NEWEST_FIELDS in tests_format_version.rs). panels/detail/masks.rs keeps main's `frame_long_norm` (it maps through `for_mask`) as `pub(super)` for the Lens Blur overlays. docs/parity-checklist.md: main's header with the Lens Blur items, both revision histories, summary refreshed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -434,7 +434,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();
|
||||
@@ -712,6 +712,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;
|
||||
@@ -750,7 +756,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"))?;
|
||||
@@ -845,15 +851,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())?;
|
||||
@@ -1107,6 +1128,18 @@ mod raw_files_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod import_target_tests {
|
||||
use super::{import_and_select, import_targets, json};
|
||||
|
||||
@@ -233,6 +233,25 @@ pub fn parse_central(cd: &[u8]) -> Result<Vec<ZipEntry>, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a restore writes, each storage key once, checked against the whole directory before any
|
||||
/// payload is read: a backup names a file once, so a directory naming the same destination again
|
||||
/// is damaged or altered, and its entries' bytes can't add up to more than the file holds (a
|
||||
/// directory can point many entries at one stored range and have each read, checked and written).
|
||||
pub fn restore_plan<'a>(entries: &'a [ZipEntry], dir: &str, file_len: u64) -> Result<Vec<(String, &'a ZipEntry)>, String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut plan = Vec::new();
|
||||
let mut bytes = 0u64;
|
||||
for e in entries {
|
||||
let Some(key) = restore_key(&e.name, dir) else { continue };
|
||||
if !seen.insert(key.clone()) {
|
||||
return Err(format!("{}: listed twice (damaged or altered backup)", e.name));
|
||||
}
|
||||
bytes = bytes.checked_add(e.size).filter(|&b| b <= file_len).ok_or_else(|| format!("{}: entries larger than the backup itself", e.name))?;
|
||||
plan.push((key, e));
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Offset of an entry's data from its local header's first 30 bytes.
|
||||
pub fn data_offset(entry: &ZipEntry, local: &[u8]) -> Result<u64, String> {
|
||||
if rd32(local, 0) != Some(LOCAL_SIG) {
|
||||
@@ -342,6 +361,23 @@ mod tests {
|
||||
assert!(!valid_library_dir("library-../x") && !valid_library_dir("originals") && !valid_library_dir("library-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_plan_refuses_repeated_and_oversized_entries() {
|
||||
let entry = |name: &str, size: u64| ZipEntry { name: name.into(), crc: 0, size, header: 0, method: 0 };
|
||||
let ok = [entry("README.txt", 10), entry("library/catalog.log", 10), entry("originals/abc123/a.jpg", 100)];
|
||||
let plan = restore_plan(&ok, "d", 200).unwrap();
|
||||
assert_eq!(plan.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(), ["d/catalog.log", "originals/abc123"]);
|
||||
// one stored range named 3 times would be read, checked and written 3 times
|
||||
let twice = [entry("originals/abc123/a.jpg", 100), entry("originals/abc123/b.jpg", 100)];
|
||||
assert!(restore_plan(&twice, "d", 1000).unwrap_err().contains("twice"));
|
||||
let twice = [entry("library/catalog.log", 10), entry("library/catalog.log", 10)];
|
||||
assert!(restore_plan(&twice, "d", 1000).is_err());
|
||||
// entries claiming more bytes than the file has
|
||||
let big = [entry("library/catalog.log", 150), entry("originals/abc123/a.jpg", 100)];
|
||||
assert!(restore_plan(&big, "d", 200).is_err());
|
||||
assert!(restore_plan(&[entry("library/catalog.log", u64::MAX), entry("library/presets.json", 1)], "d", u64::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_limits_are_errors_not_panics() {
|
||||
let mut w = ZipWriter { offset: MAX_BYTES - 10, ..Default::default() };
|
||||
|
||||
@@ -13,7 +13,7 @@ use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backup::{
|
||||
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
|
||||
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_plan, restored_dir_name, tail_len,
|
||||
valid_hash, verify, verify_original,
|
||||
};
|
||||
use crate::files::{Files, LIBRARY_FILES};
|
||||
@@ -214,8 +214,7 @@ pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, St
|
||||
}
|
||||
let dir = restored_dir_name(js_sys::Date::now());
|
||||
let mut written = 0usize;
|
||||
for e in &entries {
|
||||
let Some(key) = restore_key(&e.name, &dir) else { continue };
|
||||
for (key, e) in restore_plan(&entries, &dir, len)? {
|
||||
// originals are named by their content: one already stored is the same file
|
||||
if key.starts_with("originals/") && backend.exists(&key).await? {
|
||||
continue;
|
||||
|
||||
@@ -96,7 +96,7 @@ impl Options {
|
||||
// the benchmark edits the first photo it finds: a link with `?bench` must never reach
|
||||
// the library in browser storage, so it always runs on the demo photos in an unsaved library
|
||||
store: if bench { "memory".into() } else { get("store").filter(|s| s == "idb" || s == "memory").unwrap_or_else(|| "opfs".into()) },
|
||||
workers: get("workers").and_then(|v| v.parse().ok()),
|
||||
workers: get("workers").and_then(|v| v.parse().ok()).map(|n: usize| n.min(crate::workers::MAX_WORKERS)),
|
||||
reset: get("reset").is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,15 @@ impl Inner {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most workers started, whatever `?workers=` asks: more than this brings nothing to a
|
||||
/// render queue and a link can't make the page spawn workers (and their copies of the module)
|
||||
/// without end.
|
||||
pub const MAX_WORKERS: usize = 16;
|
||||
|
||||
/// Worker constructions failing in a row after which the rest are not tried: a browser that
|
||||
/// refused this many refuses the next ones too.
|
||||
const MAX_FAILED_STARTS: usize = 3;
|
||||
|
||||
/// The main thread's handle on the render workers.
|
||||
#[derive(Clone)]
|
||||
pub struct Workers(Rc<RefCell<Inner>>);
|
||||
@@ -244,7 +253,12 @@ impl Workers {
|
||||
return w;
|
||||
}
|
||||
let mut slots = WorkerSlots::default();
|
||||
for attempt in 0..n {
|
||||
let mut failed = 0;
|
||||
for attempt in 0..n.min(MAX_WORKERS) {
|
||||
if failed >= MAX_FAILED_STARTS {
|
||||
log::warn!("render workers: {failed} failed to start in a row; not trying the rest");
|
||||
break;
|
||||
}
|
||||
// The callbacks name their worker by its place in `workers`; a failed attempt takes none.
|
||||
let i = slots.peek();
|
||||
let opts = web_sys::WorkerOptions::new();
|
||||
@@ -254,6 +268,7 @@ impl Workers {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
log::warn!("render worker {attempt}: {e:?}");
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -274,10 +289,12 @@ impl Workers {
|
||||
worker.set_onmessage(None);
|
||||
worker.set_onerror(None);
|
||||
worker.terminate();
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
w.0.borrow_mut().workers.push(W { worker, ready: false, dead: false, busy: None });
|
||||
slots.commit();
|
||||
failed = 0;
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
@@ -167,7 +167,19 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
// A message can carry text from files and peers (a file name, a rejected value): it must not be able to start a
|
||||
// record of its own. Later lines of a multi-line message (a panic report) are indented, so none begins like a
|
||||
// record; other control characters (a bare CR, an escape sequence) are shown escaped.
|
||||
let mut safe = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
match c {
|
||||
'\n' => safe.push_str("\n "),
|
||||
'\t' => safe.push(c),
|
||||
c if c.is_control() => safe.extend(c.escape_default()),
|
||||
c => safe.push(c),
|
||||
}
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {safe}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
|
||||
@@ -167,7 +167,11 @@ fn a_line_carries_time_level_thread_target_and_message() {
|
||||
"2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
// later lines are indented and control characters escaped, so a message cannot forge a record
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n b\n"));
|
||||
let forged = format_line("t", log::Level::Info, "w", "x", "name\r\n2026-01-01T00:00:00Z ERROR [main] x: fake\u{1b}[2J");
|
||||
assert!(forged.lines().skip(1).all(|l| l.starts_with(' ')), "{forged:?}");
|
||||
assert!(!forged.contains('\r') && !forged.contains('\u{1b}'), "{forged:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -357,7 +361,7 @@ fn a_panic_report_goes_to_the_file_and_never_deadlocks() {
|
||||
logger.record_panic("main", "while the sink is held");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}");
|
||||
assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\n boom\n"), "{text}");
|
||||
assert!(!text.contains("while the sink is held"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -255,6 +255,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, remembered, .. } => open_library_session(in_memory, dir.clone(), *remembered, seed_demo),
|
||||
};
|
||||
// lens profiles from the app's own lensfun copy, offered on the first launch (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,7 +52,9 @@
|
||||
//! | 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 Blur | `DevelopSettings.lens_blur`, `Mask.adjust.lens_blur` |
|
||||
//! | 10 | masks stay on the photo under lens corrections, Upright and Transform | `Mask.on_photo` (develop settings) |
|
||||
//! | 11 | lens database profiles named in the Optics panel | `EmbeddedLens.source` |
|
||||
//! | 12 | Lens Blur | `DevelopSettings.lens_blur`, `Mask.adjust.lens_blur` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
@@ -77,7 +79,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 = 10;
|
||||
pub const VERSION: u32 = 12;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -87,6 +87,12 @@ pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec<String> {
|
||||
/// The deepest a keyword list file may nest keywords: a file is input, so its nesting is bounded.
|
||||
pub const MAX_LEVELS: usize = 64;
|
||||
|
||||
/// The most bytes of full keyword paths a keyword list file may spell out. Every line names its
|
||||
/// keyword by its whole path (`parent|child`), so a file of one long keyword with many short ones
|
||||
/// inside it would otherwise grow into far more memory than its own size (CWE-400). Lightroom
|
||||
/// Classic's own lists of tens of thousands of keywords come to a few MiB.
|
||||
pub const MAX_LIST_PATH_BYTES: usize = 64 << 20; // 64 MiB
|
||||
|
||||
/// A keyword list as written by [`Catalog::keyword_list_text`]: the file's text, the keywords in
|
||||
/// it (paths), and what the format can't hold and so is left out (a keyword, with those inside it,
|
||||
/// as its path; a synonym as `path {synonym}`).
|
||||
@@ -114,6 +120,8 @@ pub fn parse_keyword_list(text: &str) -> Result<Vec<(String, KeywordInfo)>> {
|
||||
let mut synonyms: std::collections::HashMap<usize, std::collections::HashSet<String>> = std::collections::HashMap::new();
|
||||
// the keyword at each level above the line: its path and where it is in `out`
|
||||
let mut above: Vec<(String, usize)> = Vec::new();
|
||||
// bytes of full paths spelled out so far, checked against `MAX_LIST_PATH_BYTES` before each
|
||||
let mut path_bytes = 0usize;
|
||||
for (i, line) in text.split('\n').enumerate() {
|
||||
let n = i + 1;
|
||||
if line.trim().is_empty() {
|
||||
@@ -162,6 +170,11 @@ pub fn parse_keyword_list(text: &str) -> Result<Vec<(String, KeywordInfo)>> {
|
||||
return Err(bad(n, "“|” separates a keyword's levels: a name can't hold one"));
|
||||
}
|
||||
above.truncate(depth);
|
||||
let parent_len = above.last().map_or(0, |(parent, _)| parent.len().saturating_add(1));
|
||||
path_bytes = path_bytes.saturating_add(parent_len.saturating_add(name.len()));
|
||||
if path_bytes > MAX_LIST_PATH_BYTES {
|
||||
return Err(bad(n, &format!("the keywords' full paths come to more than {} MiB", MAX_LIST_PATH_BYTES >> 20)));
|
||||
}
|
||||
let path = match above.last() {
|
||||
Some((parent, _)) => format!("{parent}{SEP}{name}"),
|
||||
None => name.to_string(),
|
||||
@@ -189,6 +202,14 @@ pub fn closest<'a>(k: &str, from: &'a [String]) -> Option<&'a String> {
|
||||
from.iter().filter(|f| is_under(k, f)).max_by_key(|f| f.split(SEP).count())
|
||||
}
|
||||
|
||||
/// Add each of `more` to `synonyms` that isn't there in any case, in order. One lowered set of
|
||||
/// what is there, not a search of the growing list per synonym: an imported list can give a
|
||||
/// keyword many synonyms, and the search was quadratic in them (CWE-407).
|
||||
fn merge_synonyms(synonyms: &mut Vec<String>, more: impl IntoIterator<Item = String>) {
|
||||
let mut seen: std::collections::HashSet<String> = synonyms.iter().map(|s| s.to_lowercase()).collect();
|
||||
synonyms.extend(more.into_iter().filter(|s| seen.insert(s.to_lowercase())));
|
||||
}
|
||||
|
||||
/// Keep the first of case-insensitively equal keywords.
|
||||
fn dedupe(v: &mut Vec<String>) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -402,11 +423,7 @@ impl Catalog {
|
||||
Some(there) => {
|
||||
there.info.new_keywords_inside |= l.info.new_keywords_inside;
|
||||
there.info.keyword_shortcut |= l.info.keyword_shortcut;
|
||||
for s in l.info.synonyms {
|
||||
if !there.info.synonyms.iter().any(|x| same(x, &s)) {
|
||||
there.info.synonyms.push(s);
|
||||
}
|
||||
}
|
||||
merge_synonyms(&mut there.info.synonyms, l.info.synonyms);
|
||||
}
|
||||
None => {
|
||||
list.insert(path.to_lowercase(), ListedKeyword { path, info: l.info });
|
||||
@@ -467,8 +484,11 @@ impl Catalog {
|
||||
let defaults = KeywordInfo::default();
|
||||
let info = |path: &str| self.keyword_info(path).unwrap_or(&defaults);
|
||||
let mut out = ExportKeywords::default();
|
||||
let push = |v: &mut Vec<String>, s: &str| {
|
||||
if !s.is_empty() && !v.iter().any(|x| same(x, s)) {
|
||||
// what each list holds, lowered: a search of the list per name was quadratic in a keyword's
|
||||
// synonyms, which an imported list sets (CWE-407)
|
||||
let (mut flat_seen, mut paths_seen) = (std::collections::HashSet::new(), std::collections::HashSet::new());
|
||||
let push = |v: &mut Vec<String>, seen: &mut std::collections::HashSet<String>, s: &str| {
|
||||
if !s.is_empty() && seen.insert(s.to_lowercase()) {
|
||||
v.push(s.to_string());
|
||||
}
|
||||
};
|
||||
@@ -481,11 +501,11 @@ impl Catalog {
|
||||
let levels: Vec<&str> = path.split(SEP).collect();
|
||||
let mut names = |at: usize, i: &KeywordInfo| {
|
||||
if let Some(name) = levels.get(at) {
|
||||
push(&mut out.flat, name);
|
||||
push(&mut out.flat, &mut flat_seen, name);
|
||||
}
|
||||
if i.export_synonyms {
|
||||
for s in &i.synonyms {
|
||||
push(&mut out.flat, s.trim());
|
||||
push(&mut out.flat, &mut flat_seen, s.trim());
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -499,7 +519,7 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
}
|
||||
push(&mut out.hierarchical, &path);
|
||||
push(&mut out.hierarchical, &mut paths_seen, &path);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -509,6 +529,13 @@ impl Catalog {
|
||||
/// keyword, a keyword left out of export in square brackets; by name. What the format can't hold
|
||||
/// is left out and named, so the file always reads back.
|
||||
pub fn keyword_list_text(&self) -> KeywordListFile {
|
||||
self.keyword_list_text_within(MAX_LIST_PATH_BYTES)
|
||||
}
|
||||
|
||||
/// [`keyword_list_text`](Self::keyword_list_text) with the full paths written held to
|
||||
/// `max_path_bytes` as [`parse_keyword_list`] holds them: a keyword past it is named
|
||||
/// unwritable, with its subtree, so the file reads back.
|
||||
fn keyword_list_text_within(&self, max_path_bytes: usize) -> KeywordListFile {
|
||||
// a name the list would read as something else, or not at all
|
||||
fn unwritable(name: &str) -> bool {
|
||||
name.trim().is_empty()
|
||||
@@ -519,14 +546,27 @@ impl Catalog {
|
||||
|| (name.starts_with('[') && name.ends_with(']'))
|
||||
|| (name.starts_with('{') && name.ends_with('}'))
|
||||
}
|
||||
fn write(c: &Catalog, nodes: &[KeywordNode], depth: usize, file: &mut KeywordListFile) {
|
||||
fn write(
|
||||
c: &Catalog,
|
||||
nodes: &[KeywordNode],
|
||||
depth: usize,
|
||||
file: &mut KeywordListFile,
|
||||
path_bytes: &mut usize,
|
||||
max_path_bytes: usize,
|
||||
parent_len: usize,
|
||||
) {
|
||||
let tabs = "\t".repeat(depth);
|
||||
for n in nodes {
|
||||
// the keywords inside go with it: under its parent they'd be other keywords
|
||||
if depth >= MAX_LEVELS || unwritable(&n.name) {
|
||||
// (a line's full path is what the reader counts against its path budget)
|
||||
// the path the reader rebuilds from the lines written: the parent as emitted, a
|
||||
// separator, the name (a child's `n.path` can spell its parent in another case)
|
||||
let emitted = if depth == 0 { n.name.len() } else { parent_len + SEP.len_utf8() + n.name.len() };
|
||||
if depth >= MAX_LEVELS || unwritable(&n.name) || path_bytes.saturating_add(emitted) > max_path_bytes {
|
||||
file.unwritable.push(n.path.clone());
|
||||
continue;
|
||||
}
|
||||
*path_bytes += emitted;
|
||||
let info = c.keyword_info(&n.path).cloned().unwrap_or_default();
|
||||
if info.include_on_export {
|
||||
file.text.push_str(&format!("{tabs}{}\n", n.name));
|
||||
@@ -536,19 +576,21 @@ impl Catalog {
|
||||
file.written.push(n.path.clone());
|
||||
// the reader trims synonyms and takes one per spelling: an empty one, or another's
|
||||
// in a different case, is no loss; a line break or a “|” is
|
||||
let mut synonyms: Vec<&str> = Vec::new();
|
||||
// (one lowered set, not a search of those written so far per synonym: quadratic
|
||||
// in a keyword's synonyms, which an imported list sets — CWE-407)
|
||||
let mut synonyms: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for s in info.synonyms.iter().map(|s| s.trim()) {
|
||||
if s.is_empty() || synonyms.iter().any(|x| same(x, s)) {
|
||||
if s.is_empty() || synonyms.contains(&s.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
if s.chars().any(char::is_control) || s.contains(SEP) {
|
||||
file.unwritable.push(format!("{} {{{s}}}", n.path));
|
||||
} else {
|
||||
synonyms.push(s);
|
||||
synonyms.insert(s.to_lowercase());
|
||||
file.text.push_str(&format!("{tabs}\t{{{s}}}\n"));
|
||||
}
|
||||
}
|
||||
write(c, &n.children, depth + 1, file);
|
||||
write(c, &n.children, depth + 1, file, path_bytes, max_path_bytes, emitted);
|
||||
}
|
||||
}
|
||||
let mut file = KeywordListFile::default();
|
||||
@@ -568,7 +610,7 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
}
|
||||
write(self, &self.keyword_tree(), 0, &mut file);
|
||||
write(self, &self.keyword_tree(), 0, &mut file, &mut 0, max_path_bytes, 0);
|
||||
file.unwritable.extend(over_deep);
|
||||
file
|
||||
}
|
||||
@@ -632,11 +674,7 @@ impl Catalog {
|
||||
// one the library has: the list's synonyms join its own (listed only if it gains some)
|
||||
let mut info_now = list.get(&key).map(|l| l.info.clone()).unwrap_or_default();
|
||||
let before = info_now.synonyms.len();
|
||||
for s in &info.synonyms {
|
||||
if !info_now.synonyms.iter().any(|x| same(x, s)) {
|
||||
info_now.synonyms.push(s.clone());
|
||||
}
|
||||
}
|
||||
merge_synonyms(&mut info_now.synonyms, info.synonyms.iter().cloned());
|
||||
if info_now.synonyms.len() > before {
|
||||
let path = list.get(&key).map_or(path, |l| l.path.clone());
|
||||
list.insert(key, ListedKeyword { path, info: info_now });
|
||||
|
||||
@@ -556,6 +556,17 @@ fn the_keyword_list_is_written_as_tab_indented_text() {
|
||||
assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n");
|
||||
}
|
||||
|
||||
/// The path budget the reader enforces holds for the writer too: a keyword past it, and its
|
||||
/// subtree, is named unwritable instead of making a file `keyword.import` rejects.
|
||||
#[test]
|
||||
fn the_keyword_list_written_stays_within_the_path_budget() {
|
||||
let (c, _) = lib(&[&["Places|Lisbon", "beach"], &["Events|Weddings"]]);
|
||||
let file = c.keyword_list_text_within(17);
|
||||
assert_eq!(file.text, "beach\nEvents\nPlaces\n");
|
||||
assert_eq!(file.unwritable, ["Events|Weddings", "Places|Lisbon"]);
|
||||
assert_eq!(c.keyword_list_text().unwritable, Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// Reading a list gives each keyword's path and attributes: what was written comes back, and a
|
||||
/// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too.
|
||||
#[test]
|
||||
@@ -765,3 +776,47 @@ fn keyword_tree_caps_a_keyword_at_the_list_depth() {
|
||||
let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0);
|
||||
assert_eq!(deepest, MAX_LEVELS);
|
||||
}
|
||||
|
||||
/// A keyword list file spells each keyword's whole path, so one long keyword with many short ones
|
||||
/// inside it expands into far more than its own size: past `MAX_LIST_PATH_BYTES` it is refused
|
||||
/// with the line where that happened, instead of growing without bound (CWE-400).
|
||||
#[test]
|
||||
fn a_list_whose_paths_expand_past_the_budget_is_refused() {
|
||||
let parent = "p".repeat(1 << 20);
|
||||
let children = MAX_LIST_PATH_BYTES / parent.len() + 2;
|
||||
let mut text = format!("{parent}\n");
|
||||
for i in 0..children {
|
||||
text.push_str(&format!("\tc{i}\n"));
|
||||
}
|
||||
let err = parse_keyword_list(&text).unwrap_err().to_string();
|
||||
assert!(err.contains("full paths come to more than"), "{err}");
|
||||
// a list within the budget still reads
|
||||
let ok = parse_keyword_list(&format!("{parent}\n\tchild\n")).unwrap();
|
||||
assert_eq!(ok.len(), 2);
|
||||
assert_eq!(ok[1].0, format!("{parent}|child"));
|
||||
}
|
||||
|
||||
/// Many synonyms joining a keyword the library has, its export and its keyword list file each keep
|
||||
/// every spelling once, in order, without a search of the growing list per synonym (CWE-407).
|
||||
#[test]
|
||||
fn many_synonyms_are_gathered_once_each_in_order() {
|
||||
const N: usize = 40_000;
|
||||
let mut c = Catalog::new();
|
||||
c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["Trip"])) }).unwrap();
|
||||
let mut incoming: Vec<String> = (0..N).map(|i| format!("syn{i}")).collect();
|
||||
incoming.push("TRIP".into());
|
||||
incoming.push("SYN7".into());
|
||||
let started = std::time::Instant::now();
|
||||
let (op, added, updated) = c.import_keywords_ops(&[("travel".into(), KeywordInfo { synonyms: incoming, ..KeywordInfo::default() })]);
|
||||
c.apply(op).unwrap();
|
||||
assert_eq!((added, updated), (0, 1));
|
||||
let synonyms = c.keyword_info("travel").unwrap().synonyms.clone();
|
||||
assert_eq!(synonyms.len(), N + 1);
|
||||
assert_eq!(synonyms[0], "Trip");
|
||||
assert_eq!(synonyms[8], "syn7");
|
||||
let exported = c.export_keywords(&["travel".into()]);
|
||||
assert_eq!(exported.flat.len(), N + 2);
|
||||
let file = c.keyword_list_text();
|
||||
assert_eq!(file.text.lines().count(), N + 2);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
@@ -636,7 +636,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,
|
||||
}
|
||||
}
|
||||
/// v10: Lens Blur in develop settings and mask adjustments.
|
||||
const NEWEST_FIELDS: u32 = 10;
|
||||
/// v12: Lens Blur in develop settings and mask adjustments (v11: `EmbeddedLens.source`; v10: `Mask.on_photo`; v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`).
|
||||
const NEWEST_FIELDS: u32 = 12;
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -160,7 +160,10 @@ pub fn write(path: &Path, img: &Rgb32f, key: &str) -> Result<(), ProductError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut f = std::io::BufWriter::new(std::fs::File::create(&tmp)?);
|
||||
// a new temp file: an entry already under the name (a link planted in the cache folder
|
||||
// would be written through) is removed, never opened
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let mut f = std::io::BufWriter::new(std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?);
|
||||
f.write_all(MAGIC)?;
|
||||
f.write_all(&[VERSION, 0, 0, 0])?;
|
||||
f.write_all(&(header.len() as u32).to_le_bytes())?;
|
||||
@@ -459,6 +462,22 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_planted_under_the_temp_name_is_not_written_through() {
|
||||
let p = temp("planted");
|
||||
let victim = temp("victim");
|
||||
std::fs::write(&victim, b"keep").unwrap();
|
||||
let _ = std::fs::remove_file(p.with_extension("tmp"));
|
||||
std::os::unix::fs::symlink(&victim, p.with_extension("tmp")).unwrap();
|
||||
write(&p, &picture(16, 4), "k").unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
|
||||
assert!(read_header_at(&p).is_ok());
|
||||
for f in [p, victim] {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
}
|
||||
|
||||
fn picture(w: usize, h: usize) -> Rgb32f {
|
||||
Rgb32f {
|
||||
width: w,
|
||||
|
||||
@@ -699,11 +699,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;
|
||||
|
||||
@@ -1078,15 +1099,35 @@ pub struct Mask {
|
||||
/// Refine Edges 0..100: the mask's edges snap to the photo's (guided filter).
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub refine: f64,
|
||||
/// Where its coordinates (gradients, brush strokes, AI selections) live: `true`, on the photo as
|
||||
/// shot (user orientation, before lens corrections, Upright and Transform), so the mask stays
|
||||
/// on the same content whatever those do; `false`, a mask from before that, on the corrected
|
||||
/// frame. Read as `false` when absent; [`Mask::default`] (every new mask) is `true`, and an
|
||||
/// edit converts an old one (`lightcraft_pipeline::mask_space::attach`).
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub on_photo: bool,
|
||||
}
|
||||
|
||||
fn is_zero(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
|
||||
impl Default for Mask {
|
||||
fn default() -> Self {
|
||||
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default(), refine: 0.0 }
|
||||
Self {
|
||||
id: 0,
|
||||
name: "Mask 1".into(),
|
||||
visible: true,
|
||||
invert: false,
|
||||
components: Vec::new(),
|
||||
adjust: LocalAdjustments::default(),
|
||||
refine: 0.0,
|
||||
on_photo: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -65,6 +65,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
let mut has_japanese_ui = false;
|
||||
@@ -82,7 +83,17 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
// a font is a file of the checkout: a manifest entry or a link reaching outside it would
|
||||
// embed some other file of the build machine
|
||||
if !path.starts_with(&root) || !path.is_file() {
|
||||
return Err(format!("{file}: not a file inside {}", root.display()));
|
||||
}
|
||||
let shown = path.display().to_string();
|
||||
// one cargo instruction per line: a line break in the path would print a second one
|
||||
if shown.contains(['\n', '\r']) {
|
||||
return Err(format!("{file}: the path contains a line break"));
|
||||
}
|
||||
println!("cargo::rerun-if-changed={shown}");
|
||||
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
|
||||
@@ -116,9 +116,9 @@ pub fn save(profile: &CameraProfile, dir: &Path) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = dir.join(file_name(&profile.model));
|
||||
let json = serde_json::to_vec_pretty(profile).map_err(|e| e.to_string())?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json).map_err(|e| format!("{}: {e}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
// a fresh temp file renamed over the profile: never a fixed `.tmp` name, which a planted link
|
||||
// would have written through
|
||||
lightcraft_catalog::safe_file::write_atomic(&path, &json).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -433,6 +433,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut plans: Vec<crate::rename::RenamePlan> = Vec::new();
|
||||
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut moving: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
let mut clashes: Vec<String> = Vec::new();
|
||||
for id in ids {
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?;
|
||||
@@ -442,7 +443,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lightcraft_catalog::Source::File { path } = &ph.source else {
|
||||
return Err(bad(C, format!("{} has no file on disk", ph.file_name)));
|
||||
};
|
||||
if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) {
|
||||
if !moving.insert(path.as_str()) {
|
||||
continue; // a virtual copy of a photo already moving
|
||||
}
|
||||
let src = Path::new(path);
|
||||
@@ -521,7 +522,13 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
|
||||
if !src.is_dir() {
|
||||
return Err(format!("{from} is not a folder"));
|
||||
}
|
||||
if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) {
|
||||
// compared as the folders are on disk (a differently spelled or linked path to the library
|
||||
// names the same folder), with the spelling as a fallback where that can't be told
|
||||
let same_folder = |l: &Path| {
|
||||
let (lib, chosen) = (l.canonicalize().unwrap_or_else(|_| l.to_path_buf()), src.canonicalize().unwrap_or_else(|_| src.to_path_buf()));
|
||||
lib.starts_with(&chosen) || chosen.starts_with(&lib) || l.starts_with(src) || src.starts_with(l)
|
||||
};
|
||||
if s.library.as_ref().is_some_and(|l| same_folder(&l.dir)) {
|
||||
return Err("the library's own folders can't be moved here".into());
|
||||
}
|
||||
// Repairs retain their exact old paths, including those on surviving history entries.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! 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 (downloaded on the user's request) 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();
|
||||
// each photo's virtual copies, gathered once for the whole batch (the catalog doesn't change until it's applied)
|
||||
let mut copies: std::collections::HashMap<PhotoId, Vec<PhotoId>> = std::collections::HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
if let Some(original) = p.copy_of {
|
||||
copies.entry(original).or_default().push(p.id);
|
||||
}
|
||||
}
|
||||
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 = std::iter::once(item.id).chain(copies.get(&item.id).into_iter().flatten().copied());
|
||||
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 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 offers it on its first launch and in Settings. 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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -182,12 +182,17 @@ pub(super) fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
||||
let targets = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
if let Some(d) = s.develop_of(id) {
|
||||
let mut d = (*d).clone();
|
||||
if let Some(before) = s.develop_of(id) {
|
||||
let mut d = (*before).clone();
|
||||
d.orientation = d.orientation.rotated(cw);
|
||||
// the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo)
|
||||
let turn = lightcraft_geom::Orientation::Normal.rotated(cw);
|
||||
d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect);
|
||||
// and so do the masks: on the photo first (an old one), then turned with it
|
||||
s.attach_masks(id, &before, &mut d);
|
||||
for m in &mut d.masks {
|
||||
lightcraft_pipeline::mask_space::turn(m, turn);
|
||||
}
|
||||
d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w));
|
||||
ops.extend(s.develop_op(id, d, "Rotate"));
|
||||
}
|
||||
|
||||
@@ -757,25 +757,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
// keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case
|
||||
use lightcraft_catalog::keywords::{clean, same};
|
||||
let add = |m: &mut lightcraft_catalog::Meta, k: &str| {
|
||||
use lightcraft_catalog::keywords::clean;
|
||||
// (a set of the lower-cased keywords keeps this linear however many are supplied)
|
||||
let mut seen: std::collections::HashSet<String> = m.keywords.iter().map(|x| x.to_lowercase()).collect();
|
||||
let add = |m: &mut lightcraft_catalog::Meta, seen: &mut std::collections::HashSet<String>, k: &str| {
|
||||
let k = clean(k);
|
||||
if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {
|
||||
if !k.is_empty() && seen.insert(k.to_lowercase()) {
|
||||
m.keywords.push(k);
|
||||
}
|
||||
};
|
||||
if let Some(k) = strs("keywords") {
|
||||
m.keywords.clear();
|
||||
seen.clear();
|
||||
for k in k {
|
||||
add(&mut m, &k);
|
||||
add(&mut m, &mut seen, &k);
|
||||
}
|
||||
}
|
||||
for k in strs("addKeywords").unwrap_or_default() {
|
||||
add(&mut m, &k);
|
||||
add(&mut m, &mut seen, &k);
|
||||
}
|
||||
for k in strs("removeKeywords").unwrap_or_default() {
|
||||
let k = clean(&k);
|
||||
m.keywords.retain(|x| !same(&clean(x), &k));
|
||||
let removed: std::collections::HashSet<String> =
|
||||
strs("removeKeywords").unwrap_or_default().iter().map(|k| clean(k).to_lowercase()).collect();
|
||||
if !removed.is_empty() {
|
||||
m.keywords.retain(|x| !removed.contains(&clean(x).to_lowercase()));
|
||||
}
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
}
|
||||
|
||||
@@ -120,7 +120,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let file = match &dir {
|
||||
Some(d) => {
|
||||
let f = d.join(format!("{}.cube", id.trim_start_matches("lut:")));
|
||||
std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
|
||||
// a new file renamed into place: an entry already under the name (a link planted
|
||||
// in the Profiles folder) is replaced, never written through
|
||||
lightcraft_catalog::safe_file::write_atomic(&f, text.as_bytes()).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
|
||||
f.to_string_lossy().to_string()
|
||||
}
|
||||
// no library on disk: the registered LUT is all this session needs (a copy would only
|
||||
|
||||
@@ -468,6 +468,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let exclude = bool_or(p, "exclude", false);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
// an old mask (on the corrected frame, as the click then is) goes onto the photo
|
||||
// first, and the click with it: the model works on the photo before lens
|
||||
// corrections, Upright and Transform, and reads every click there
|
||||
let mut at = at;
|
||||
if let Some(cur) = s.develop_of(id).filter(|d| d.masks.iter().any(|m| m.id == mid && !m.on_photo)) {
|
||||
if let Some(f) = s.mask_frame(id, &cur) {
|
||||
// a click on the corrected frame's blank space is off the photo, not on
|
||||
// its edge (a hair past it, from rounding, is on the edge)
|
||||
const EDGE: f64 = 1e-6;
|
||||
let n = f.for_mask(true).norm_to_mask(at);
|
||||
let on = |v: f64| v.is_finite() && (-EDGE..=1.0 + EDGE).contains(&v);
|
||||
if !(on(n.x) && on(n.y)) {
|
||||
return Err(bad(c, "the point is outside the photo"));
|
||||
}
|
||||
at = Point::new(n.x.clamp(0.0, 1.0), n.y.clamp(0.0, 1.0));
|
||||
}
|
||||
let mut on = (*cur).clone();
|
||||
s.attach_masks(id, &cur, &mut on);
|
||||
s.apply_silent(lightcraft_catalog::Op::SetDevelop {
|
||||
id,
|
||||
settings: std::sync::Arc::new(on),
|
||||
label: "Masks".into(),
|
||||
edited: None,
|
||||
})?;
|
||||
}
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
|
||||
let k = m
|
||||
|
||||
@@ -163,7 +163,11 @@ fn stored_hash(p: &Photo) -> Option<&str> {
|
||||
/// The content hash of the file at `path` (as import computes it), if it can be read. Read in
|
||||
/// pieces: the file is whatever is on the disk, of any size.
|
||||
fn file_hash(path: &str) -> Option<String> {
|
||||
hash_reader(std::fs::File::open(path).ok()?)
|
||||
use std::io::Read;
|
||||
// the opened file must be regular (a FIFO or device would never end), and no more than its size at open is read
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let meta = file.metadata().ok().filter(|m| m.is_file())?;
|
||||
hash_reader(file.take(meta.len()))
|
||||
}
|
||||
|
||||
/// The content hash of what `r` gives, read in pieces (the hash import computes of the whole).
|
||||
@@ -230,6 +234,9 @@ mod tests {
|
||||
std::fs::write(&f, b"pixels").unwrap();
|
||||
assert_eq!(file_hash(f.to_str().unwrap()), Some(lightcraft_preview::hash_bytes(b"pixels").to_string()));
|
||||
assert_eq!(file_hash(dir.0.join("nope").to_str().unwrap()), None);
|
||||
assert_eq!(file_hash(dir.0.to_str().unwrap()), None, "a folder has no content hash");
|
||||
#[cfg(unix)]
|
||||
assert_eq!(file_hash("/dev/zero"), None, "a device never ends");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 offer the lens database at its start, and fetch it there once the user said yes
|
||||
/// ([`crate::lensfun_download::fetch_on_start`] decides). 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());
|
||||
|
||||
@@ -5,7 +5,8 @@ use lightcraft_denoise::manifest::{Domain, Gain};
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("lc-engine-gpu-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// exclusive: a directory planted at this predictable name fails the test instead of receiving its files
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
|
||||
@@ -214,9 +214,9 @@ impl Index {
|
||||
for (k, e) in &self.entries {
|
||||
buf.extend(self.record(k, e));
|
||||
}
|
||||
let part = path.with_extension("part");
|
||||
std::fs::write(&part, &buf)?;
|
||||
std::fs::rename(&part, &path)?;
|
||||
// a fresh temp file (never a `.part` entry left in the library, which could be a link
|
||||
// to a file outside it) renamed over the cache
|
||||
lightcraft_catalog::safe_file::write_atomic_nosync(&path, &buf)?;
|
||||
self.file_matches = true;
|
||||
self.rewrite = false;
|
||||
}
|
||||
@@ -367,9 +367,9 @@ impl Scanned {
|
||||
} else {
|
||||
let mut buf = self.header();
|
||||
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
|
||||
let part = path.with_extension("part");
|
||||
std::fs::write(&part, &buf)?;
|
||||
std::fs::rename(&part, &path)?;
|
||||
// a fresh temp file (never a `.part` entry left in the library, which could be a link
|
||||
// to a file outside it) renamed over the cache
|
||||
lightcraft_catalog::safe_file::write_atomic_nosync(&path, &buf)?;
|
||||
self.file_matches = true;
|
||||
self.rewrite = false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ use super::{HashMap, Op, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Sour
|
||||
pub(super) struct Plan {
|
||||
pub(super) slots: Vec<Slot>,
|
||||
pub(super) transfers: Vec<Transfer>,
|
||||
/// The content hashes and name families of [`Plan::transfers`], so each new file is checked against them in
|
||||
/// constant time rather than by scanning the pending transfers.
|
||||
pub(super) pending_hashes: std::collections::HashSet<String>,
|
||||
pub(super) pending_families: std::collections::HashSet<(String, String)>,
|
||||
}
|
||||
|
||||
pub(super) enum Slot {
|
||||
@@ -26,9 +30,6 @@ pub(super) struct Transfer {
|
||||
pub(super) how: How,
|
||||
/// Copy as DNG.
|
||||
pub(super) dng: bool,
|
||||
/// The folder (lowercase) and [`name_family`]: transfers of one family can take each other's
|
||||
/// names, so they never run at the same time.
|
||||
pub(super) family: (String, String),
|
||||
}
|
||||
|
||||
pub(super) enum How {
|
||||
|
||||
@@ -329,7 +329,7 @@ impl ImportJob {
|
||||
// a copy of a file whose transfer is still pending: a duplicate only if that one succeeds
|
||||
if !opts.local
|
||||
&& let Some(h) = &info.content_hash
|
||||
&& plan.transfers.iter().any(|t| t.info.content_hash.as_ref() == Some(h))
|
||||
&& plan.pending_hashes.contains(h)
|
||||
{
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
@@ -403,7 +403,7 @@ impl ImportJob {
|
||||
// into the same folder ends up with: that one goes first
|
||||
let as_named = name.as_deref().unwrap_or(if own.is_empty() { "photo" } else { own.as_str() });
|
||||
let family = (dir.to_string_lossy().to_lowercase(), name_family(as_named));
|
||||
if plan.transfers.iter().any(|t| t.family == family) {
|
||||
if plan.pending_families.contains(&family) {
|
||||
self.run_plan(&mut plan, &mut out, cancel);
|
||||
if cancel.load(Relaxed) {
|
||||
break;
|
||||
@@ -421,7 +421,10 @@ impl ImportJob {
|
||||
// Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace)
|
||||
let dng = opts.convert_dng && mode == ImportMode::Copy && raw && !info.format.eq_ignore_ascii_case("DNG");
|
||||
plan.slots.push(Slot::Transfer);
|
||||
plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng, family });
|
||||
// (transfers of one folder and name family can take each other's names, so they never run together)
|
||||
plan.pending_families.insert(family);
|
||||
plan.pending_hashes.extend(info.content_hash.clone());
|
||||
plan.transfers.push(Transfer { path, info, sidecar, dir, how, dng });
|
||||
continue;
|
||||
}
|
||||
(ImportMode::Move, _) => {
|
||||
@@ -444,6 +447,8 @@ impl ImportJob {
|
||||
/// Run the transfers `plan` collected (on several threads) and append its items to `out` in
|
||||
/// file order. Transfers not started because `cancel` was set leave no item.
|
||||
fn run_plan(&mut self, plan: &mut Plan, out: &mut Prepared, cancel: &std::sync::atomic::AtomicBool) {
|
||||
plan.pending_hashes.clear();
|
||||
plan.pending_families.clear();
|
||||
let mut done = run_transfers(std::mem::take(&mut plan.transfers), self.file_bytes.as_ref(), cancel, &self.done).into_iter();
|
||||
for slot in std::mem::take(&mut plan.slots) {
|
||||
let item = match slot {
|
||||
|
||||
@@ -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;
|
||||
//! [`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 offers on its first launch and Settings can start), 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;
|
||||
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,
|
||||
@@ -49,10 +140,10 @@ pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orient
|
||||
db.find_lens_named(m.lens_make.as_deref(), &name, camera)?
|
||||
}
|
||||
};
|
||||
let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm);
|
||||
let crop = frame_crop_factor(camera.and_then(|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));
|
||||
@@ -177,6 +271,34 @@ mod tests {
|
||||
assert_eq!(frame_crop_factor(Some(1.0), 0.0, Some(105.0)), Some(1.0), "a hostile focal length is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_camera_crop_uses_exif_equivalent_without_full_frame_assumption() {
|
||||
for crop in [
|
||||
"",
|
||||
"<cropfactor/>",
|
||||
"<cropfactor>bad</cropfactor>",
|
||||
"<cropfactor>NaN</cropfactor>",
|
||||
"<cropfactor>inf</cropfactor>",
|
||||
"<cropfactor>0.049</cropfactor>",
|
||||
"<cropfactor>21</cropfactor>",
|
||||
] {
|
||||
let xml = DB.replacen("<cropfactor>1.5</cropfactor>", crop, 1).replace("-0.03", "-0.003").replace("-0.4", "-0.04");
|
||||
let db = LensDb::parse(&xml).unwrap();
|
||||
// A below-1 crop exposes the old synthetic 1.0 body: the 10% crop-mode policy would keep it.
|
||||
let m = lightcraft_meta::Metadata { focal_length_35mm: Some(17.1), ..meta() };
|
||||
let expected = db.lenses[0]
|
||||
.correction(&Shot { focal: 18.0, aperture: Some(3.5), distance: None, crop_factor: Some(17.1 / 18.0), width: 6000.0, height: 4000.0 })
|
||||
.map(|lens| EmbeddedLens { source: LensSource::Database, ..lens })
|
||||
.unwrap();
|
||||
assert_eq!(lookup(&db, &m, 6000.0, 4000.0, Orientation::Normal).unwrap(), expected, "{crop}");
|
||||
let fallback = db.lenses[0]
|
||||
.correction(&Shot { focal: 18.0, aperture: Some(3.5), distance: None, crop_factor: None, width: 6000.0, height: 4000.0 })
|
||||
.map(|lens| EmbeddedLens { source: LensSource::Database, ..lens })
|
||||
.unwrap();
|
||||
assert_eq!(lookup(&db, &meta(), 6000.0, 4000.0, Orientation::Normal).unwrap(), fallback, "no equivalent: {crop}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry
|
||||
/// stage with straight lines.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,837 @@
|
||||
//! The lensfun lens database, downloaded on request: the desktop app offers it once on the first launch
|
||||
//! ([`fetch_on_start`]: nothing is fetched without the user's click).
|
||||
//!
|
||||
//! 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, when the user chooses Download, 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";
|
||||
/// The suffix of the file that says a download into the folder is under way (another window or session of the
|
||||
/// app may be the one downloading).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const LOCK: &str = "lock";
|
||||
/// 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. Done
|
||||
/// under the folder's download lock (`DirLock`) and skipped while a download, in this or another LightCraft, holds
|
||||
/// it: that download is between its own two moves, not interrupted, and puts the folder right itself.
|
||||
pub fn recover(dir: &Path) {
|
||||
if !needs_recovery(dir) {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _lock = match DirLock::take(dir) {
|
||||
Ok(lock) => lock,
|
||||
Err(e) => {
|
||||
log::warn!("lens database: the previous database is not put back now: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
recover_locked(dir);
|
||||
}
|
||||
|
||||
/// What a swap interrupted between its two moves leaves: no database folder, and a whole previous one.
|
||||
fn needs_recovery(dir: &Path) -> bool {
|
||||
!dir.exists() && complete(&beside(dir, PREVIOUS))
|
||||
}
|
||||
|
||||
/// [`recover`] by a caller that already holds the folder's lock (a download).
|
||||
fn recover_locked(dir: &Path) {
|
||||
if needs_recovery(dir)
|
||||
&& let Err(e) = std::fs::rename(beside(dir, PREVIOUS), dir)
|
||||
{
|
||||
log::warn!("lens database: could not put back the previous database: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's answer to the first-launch offer of the database (the desktop app keeps it in its settings).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Offer {
|
||||
/// Not asked yet: the first start asks.
|
||||
#[default]
|
||||
Ask,
|
||||
/// Download was clicked (in the offer or in Settings): a start downloads the pinned database when it is missing
|
||||
/// or from an older pin (an interrupted or failed download is tried again).
|
||||
Accepted,
|
||||
/// Not Now: nothing is downloaded and the start doesn't ask again (Settings can download it, or turn the offer
|
||||
/// back on).
|
||||
Declined,
|
||||
}
|
||||
|
||||
/// What a start does about the database ([`fetch_on_start`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Start {
|
||||
Nothing,
|
||||
/// Ask the user (once): download into this folder, or not now.
|
||||
Ask(PathBuf),
|
||||
/// Download into this folder (the user said yes before).
|
||||
Fetch(PathBuf),
|
||||
}
|
||||
|
||||
/// The first-start policy, in one place: what a start does about the database. Nothing touches the network
|
||||
/// without the user's click: the host must allow the question at all (`auto`, [`crate::Session::lens_auto_download`],
|
||||
/// the desktop app only), a folder of the user's own (`LIGHTCRAFT_LENSFUN_DIR`) is never replaced, an installed
|
||||
/// database needs nothing, and otherwise the user's answer (`offer`) decides: ask, download, or nothing.
|
||||
pub fn fetch_on_start(auto: bool, offer: Offer) -> Start {
|
||||
let own = std::env::var_os(crate::lens_profiles::ENV).is_some_and(|v| !v.is_empty());
|
||||
let dir = default_dir();
|
||||
if let Some(d) = dir.as_deref().filter(|_| auto && !own) {
|
||||
recover(d);
|
||||
}
|
||||
let installed = dir.as_deref().is_some_and(installed);
|
||||
decide(auto, own, dir, installed, offer)
|
||||
}
|
||||
|
||||
/// [`fetch_on_start`] without the file system: `own` (`LIGHTCRAFT_LENSFUN_DIR` is set), the app's folder `dir` and
|
||||
/// whether it holds the pinned data.
|
||||
pub fn decide(auto: bool, own: bool, dir: Option<PathBuf>, installed: bool, offer: Offer) -> Start {
|
||||
let Some(dir) = dir.filter(|_| auto && !own && !installed) else { return Start::Nothing };
|
||||
match offer {
|
||||
Offer::Ask => Start::Ask(dir),
|
||||
Offer::Accepted => Start::Fetch(dir),
|
||||
Offer::Declined => Start::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// The download's size in bytes: the pinned files' sizes added up (what the offer says).
|
||||
pub fn download_size() -> u64 {
|
||||
FILES.iter().map(|f| f.1).fold(0u64, u64::saturating_add)
|
||||
}
|
||||
|
||||
/// [`download_size`] in whole megabytes for people ("about 5 MB"), never 0.
|
||||
pub fn download_megabytes() -> u64 {
|
||||
(download_size().saturating_add(500_000) / 1_000_000).max(1)
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Held while one download (or a [`recover`]) uses a folder's staging and previous folders, across sessions and
|
||||
/// processes: an exclusive operating-system lock on a file beside the folder (`File::try_lock`: `flock` on Unix,
|
||||
/// `LockFileEx` on Windows), held by the open file. The system lets go of it when the file closes, on drop or when the
|
||||
/// process ends however it ends, so an app that crashed never leaves the folder locked and no age guess is needed.
|
||||
/// The file itself stays (empty): removing it would let a second app lock a new file of the same name while the first
|
||||
/// still holds the old one. Dropping it unlocks explicitly: a process another thread is spawning holds a copy of the
|
||||
/// file until it execs, and on Unix the lock would last as long as that copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct DirLock {
|
||||
file: std::fs::File,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Drop for DirLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.file.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl DirLock {
|
||||
fn take(dir: &Path) -> Result<DirLock, String> {
|
||||
let path = beside(dir, LOCK);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?;
|
||||
}
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("could not create {}: {e}", path.display()))?;
|
||||
match file.try_lock() {
|
||||
Ok(()) => Ok(DirLock { file }),
|
||||
Err(std::fs::TryLockError::WouldBlock) => Err("the lens database is already downloading (in another LightCraft window)".into()),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(format!("could not lock {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn fetch_all(dir: &Path, mirrors: &[String], files: &[Pin], shared: &Shared) -> Result<usize, String> {
|
||||
// (from before the recovery through the swap: another download of this folder would move the same folders)
|
||||
let _lock = DirLock::take(dir)?;
|
||||
recover_locked(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);
|
||||
let had = dir.exists();
|
||||
// left by a swap that ended after its second move: `dir` is the database, so this copy can go. Without `dir`
|
||||
// (a recovery that couldn't put it back) it may be the only whole database: kept until the new one is in place
|
||||
if had && previous.exists() {
|
||||
std::fs::remove_dir_all(&previous).map_err(|e| format!("could not remove {}: {e}", previous.display()))?;
|
||||
}
|
||||
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 previous.exists()
|
||||
&& 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;
|
||||
|
||||
/// Two downloads into one folder (two windows, or two sessions of one app) never share its staging and previous
|
||||
/// folders: the second is refused while the first holds the folder, however old the first's lock file is, and
|
||||
/// may go once it ends; the file an app that quit mid-download leaves holds nothing (the system let go of it).
|
||||
#[test]
|
||||
fn one_download_at_a_time_holds_a_folder() {
|
||||
let d = scratch("lock");
|
||||
let _ = std::fs::remove_file(beside(&d, LOCK));
|
||||
let first = DirLock::take(&d).unwrap();
|
||||
// (a download running for a week on a slow line: its lock file is old, and it still holds the folder)
|
||||
let old = std::time::SystemTime::now() - Duration::from_secs(7 * 24 * 60 * 60);
|
||||
std::fs::OpenOptions::new().write(true).open(beside(&d, LOCK)).unwrap().set_modified(old).unwrap();
|
||||
for _ in 0..3 {
|
||||
let second = DirLock::take(&d);
|
||||
assert!(second.as_ref().is_err_and(|e| e.contains("already downloading")), "{:?}", second.as_ref().err());
|
||||
}
|
||||
drop(first);
|
||||
drop(DirLock::take(&d).unwrap());
|
||||
// (what an app that quit mid-download leaves: the file, unlocked)
|
||||
assert!(beside(&d, LOCK).exists());
|
||||
drop(DirLock::take(&d).unwrap());
|
||||
let _ = std::fs::remove_file(beside(&d, LOCK));
|
||||
}
|
||||
|
||||
/// The start's recovery moves nothing while a download holds the folder: between that download's two moves the
|
||||
/// folder is missing and the previous one whole, which is not an interrupted swap.
|
||||
#[test]
|
||||
fn recovery_leaves_a_folder_a_download_holds() {
|
||||
let dir = scratch("recover-locked");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let previous = beside(&dir, PREVIOUS);
|
||||
std::fs::create_dir_all(&previous).unwrap();
|
||||
std::fs::write(previous.join(MARKER), "older").unwrap();
|
||||
let held = DirLock::take(&dir).unwrap();
|
||||
recover(&dir);
|
||||
assert!(!dir.exists() && complete(&previous), "a download's swap is left alone");
|
||||
drop(held);
|
||||
recover(&dir);
|
||||
assert!(complete(&dir) && !previous.exists(), "an interrupted swap is put back");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_file(beside(&dir, LOCK));
|
||||
}
|
||||
|
||||
/// The lock holds across processes: while another process (another LightCraft) holds the folder, a download
|
||||
/// here is refused; once that process is gone (killed, like a crash), the system has let go and it may start.
|
||||
#[test]
|
||||
fn another_process_holding_the_folder_refuses_a_download_here() {
|
||||
let dir = scratch("lock-child");
|
||||
let ready = dir.join("child-locked");
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let mut child = std::process::Command::new(exe)
|
||||
.args(["--exact", "lensfun_download::tests::lock_holder_child", "--ignored", "--test-threads=1"])
|
||||
.env("LC_LENSFUN_LOCK_CHILD_DIR", &dir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(60);
|
||||
while !ready.exists() {
|
||||
assert!(child.try_wait().unwrap().is_none(), "the child exited before locking");
|
||||
assert!(std::time::Instant::now() < deadline, "the child never locked");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let here = DirLock::take(&dir);
|
||||
let refused = here.as_ref().is_err_and(|e| e.contains("already downloading"));
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
assert!(refused, "{:?}", here.as_ref().err());
|
||||
drop(DirLock::take(&dir).expect("the system let go of the dead process's lock"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_file(beside(&dir, LOCK));
|
||||
}
|
||||
|
||||
/// Run by [`another_process_holding_the_folder_refuses_a_download_here`] in a child process: take the folder's
|
||||
/// lock, say so, and wait to be killed.
|
||||
#[test]
|
||||
#[ignore = "helper process for another_process_holding_the_folder_refuses_a_download_here"]
|
||||
fn lock_holder_child() {
|
||||
let Some(dir) = std::env::var_os("LC_LENSFUN_LOCK_CHILD_DIR") else { return };
|
||||
let dir = PathBuf::from(dir);
|
||||
let _held = DirLock::take(&dir).unwrap();
|
||||
std::fs::write(dir.join("child-locked"), b"").unwrap();
|
||||
std::thread::sleep(Duration::from_secs(120));
|
||||
}
|
||||
|
||||
/// A swap into a folder that is missing, with the previous database still beside it (a recovery that couldn't
|
||||
/// put it back), keeps that previous database until the new one is in place: when the new one can't be moved
|
||||
/// in, the previous one is still there for the next start to put back.
|
||||
#[test]
|
||||
fn a_failed_swap_keeps_the_only_whole_database() {
|
||||
let dir = scratch("keep-previous");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let previous = beside(&dir, PREVIOUS);
|
||||
std::fs::create_dir_all(&previous).unwrap();
|
||||
std::fs::write(previous.join(MARKER), "older").unwrap();
|
||||
let staged = beside(&dir, STAGING);
|
||||
std::fs::create_dir_all(&staged).unwrap();
|
||||
std::fs::write(staged.join(MARKER), COMMIT).unwrap();
|
||||
let err = publish(&staged, &dir, |_, _| Err(std::io::Error::other("in use"))).unwrap_err();
|
||||
assert!(err.contains("in use"), "{err}");
|
||||
assert!(complete(&previous), "the previous database is kept");
|
||||
recover(&dir);
|
||||
assert_eq!(std::fs::read_to_string(dir.join(MARKER)).unwrap(), "older", "and the next start puts it back");
|
||||
// a swap that works into a missing folder removes the previous copy once the new one is in place
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&previous).unwrap();
|
||||
std::fs::write(previous.join(MARKER), "older").unwrap();
|
||||
publish(&staged, &dir, |from, to| std::fs::rename(from, to)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(dir.join(MARKER)).unwrap(), COMMIT);
|
||||
assert!(!previous.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_file(beside(&dir, LOCK));
|
||||
}
|
||||
|
||||
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 first start asks before anything is downloaded; Download (or Settings) means a start fetches a missing
|
||||
/// database; Not Now means nothing, ever, on its own; an installed database, a folder of the user's own or a host
|
||||
/// that may not ask (CLI, MCP) mean nothing whatever the answer.
|
||||
#[test]
|
||||
fn the_start_asks_once_and_downloads_only_after_a_yes() {
|
||||
let dir = || Some(PathBuf::from("lensfun"));
|
||||
assert_eq!(decide(true, false, dir(), false, Offer::Ask), Start::Ask(PathBuf::from("lensfun")), "the first start asks");
|
||||
assert_eq!(decide(true, false, dir(), false, Offer::Accepted), Start::Fetch(PathBuf::from("lensfun")));
|
||||
assert_eq!(decide(true, false, dir(), false, Offer::Declined), Start::Nothing, "Not Now: never asked again");
|
||||
for offer in [Offer::Ask, Offer::Accepted, Offer::Declined] {
|
||||
assert_eq!(decide(true, false, dir(), true, offer), Start::Nothing, "installed: no question, no download");
|
||||
assert_eq!(decide(false, false, dir(), false, offer), Start::Nothing, "a headless host never asks or fetches");
|
||||
assert_eq!(decide(true, true, dir(), false, offer), Start::Nothing, "the user's own folder");
|
||||
assert_eq!(decide(true, false, None, false, offer), Start::Nothing, "no data folder");
|
||||
}
|
||||
// the size the offer names comes from the pins
|
||||
assert_eq!(download_size(), FILES.iter().map(|f| f.1).sum::<u64>());
|
||||
assert!((4..=8).contains(&download_megabytes()), "{}", download_megabytes());
|
||||
}
|
||||
|
||||
/// 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 offer the lens database at its start, and fetch it after the user's yes
|
||||
/// ([`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"))]
|
||||
@@ -1023,6 +1031,30 @@ impl Session {
|
||||
/// The op that sets a photo's develop settings and appends a History entry (for batches).
|
||||
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
|
||||
self.catalog.photo(id)?;
|
||||
let mut settings = settings;
|
||||
// old masks (on the corrected frame) go onto the photo, from the frame they were drawn on:
|
||||
// masks this edit kept, under the photo's settings before it (a geometry edit then moves
|
||||
// the photo, not them); masks it brought in (paste, sync, a preset, a history step, a
|
||||
// sidecar), under the settings they came with, where they render now. Kept masks also
|
||||
// turn with an orientation change the edit makes (a sync or paste of the Crop group).
|
||||
let before = match &self.interaction {
|
||||
Some(i) if i.photo == id => Some(i.original.clone()),
|
||||
_ => self.develop_of(id),
|
||||
};
|
||||
let turn = before.as_ref().and_then(|b| orientation_change(b.orientation, settings.orientation));
|
||||
if settings.masks.iter().any(|m| !m.on_photo) || turn.is_some() {
|
||||
let kept_frame = before.as_ref().and_then(|b| self.mask_frame(id, b));
|
||||
let new_frame = self.mask_frame(id, &settings);
|
||||
for m in &mut settings.masks {
|
||||
let kept = before.as_ref().is_some_and(|b| b.masks.contains(m));
|
||||
if let Some(f) = if kept { kept_frame.as_ref() } else { new_frame.as_ref() } {
|
||||
lightcraft_pipeline::mask_space::attach(m, f);
|
||||
}
|
||||
if let (true, Some(t)) = (kept, turn) {
|
||||
lightcraft_pipeline::mask_space::turn(m, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
let settings = Arc::new(settings);
|
||||
let step = lightcraft_catalog::HistoryStep { label: label.into(), settings: settings.clone() };
|
||||
Some(Op::Batch {
|
||||
@@ -1030,11 +1062,45 @@ impl Session {
|
||||
})
|
||||
}
|
||||
|
||||
/// Put the old masks of `d` (on the corrected frame, `Mask::on_photo` false) on photo `id`
|
||||
/// before the warp, from the frame of `drawn` (the settings they were drawn under): they
|
||||
/// render where they did and from then on stay on their content when lens corrections,
|
||||
/// Upright or Transform change.
|
||||
pub fn attach_masks(&self, id: PhotoId, drawn: &DevelopSettings, d: &mut DevelopSettings) {
|
||||
if d.masks.iter().all(|m| m.on_photo) {
|
||||
return;
|
||||
}
|
||||
let Some(frame) = self.mask_frame(id, drawn) else { return };
|
||||
for m in &mut d.masks {
|
||||
lightcraft_pipeline::mask_space::attach(m, &frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// The uncropped frame of photo `id` under settings `d`, with its lens: where mask
|
||||
/// coordinates go through its warp ([`lightcraft_pipeline::geometry::Frame::for_mask`]).
|
||||
pub(crate) fn mask_frame(&self, id: PhotoId, d: &DevelopSettings) -> Option<lightcraft_pipeline::geometry::Frame> {
|
||||
let p = self.catalog.photo(id)?;
|
||||
Some(lightcraft_pipeline::geometry::Frame::with_lens(
|
||||
p.width.max(1) as usize,
|
||||
p.height.max(1) as usize,
|
||||
&d.effective(),
|
||||
false,
|
||||
p.embedded_lens.as_ref(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
|
||||
if self.interaction.is_some() {
|
||||
self.end_interaction()?;
|
||||
}
|
||||
let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?;
|
||||
// old masks go onto the photo before a drag moves the frame under them (they then stay on
|
||||
// their content while it does, not only once it ends)
|
||||
if let Some(d) = self.develop_of(id).filter(|d| d.masks.iter().any(|m| !m.on_photo)) {
|
||||
let mut on = (*d).clone();
|
||||
self.attach_masks(id, &d, &mut on);
|
||||
self.apply_silent(Op::SetDevelop { id, settings: Arc::new(on), label: label.into(), edited: None })?;
|
||||
}
|
||||
let original = self.develop_of(id).unwrap_or_default();
|
||||
self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false });
|
||||
Ok(())
|
||||
@@ -1373,3 +1439,22 @@ mod tests;
|
||||
use tests::face_recognize as tests_face_recognize;
|
||||
#[cfg(test)]
|
||||
use tests::xmp as tests_xmp;
|
||||
|
||||
/// The turn or flip that takes orientation `from` to `to` (`None` when they are the same): what the
|
||||
/// masks, on the oriented photo, must follow ([`lightcraft_pipeline::mask_space::turn`]).
|
||||
pub(crate) fn orientation_change(from: lightcraft_geom::Orientation, to: lightcraft_geom::Orientation) -> Option<lightcraft_geom::Orientation> {
|
||||
use lightcraft_geom::Orientation;
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
let inv = from.inverse();
|
||||
let probes = [(0.2, 0.3), (0.7, 0.1), (0.4, 0.9)];
|
||||
let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9;
|
||||
(0..8u8).map(|k| Orientation::from_parts(k >= 4, k % 4)).find(|t| {
|
||||
probes.iter().all(|&(x, y)| {
|
||||
let (rx, ry) = inv.map(x, y, 1.0, 1.0);
|
||||
let (tx, ty) = from.map(rx, ry, 1.0, 1.0);
|
||||
close(t.map(tx, ty, 1.0, 1.0), to.map(rx, ry, 1.0, 1.0))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::render::{RenderJob, RenderResult};
|
||||
use super::source::{PreviewLoader, SourceLevel};
|
||||
use super::{Arc, Deserialize, Hash128, Histogram, Photo, PhotoId, PreviewCache, Rendered, Rgba8, Serialize, Source, SourceInfo};
|
||||
use super::{Arc, Deserialize, Hash128, Hasher128, Histogram, Photo, PhotoId, PreviewCache, Rendered, Rgba8, Serialize, Source, SourceInfo};
|
||||
|
||||
/// Where a [`QuickJob`]'s stand-in image came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -99,6 +99,20 @@ impl QuickJob {
|
||||
}
|
||||
|
||||
/// What identifies a photo's pixels for caching: its content hash, else its source.
|
||||
/// Which decode of a photo a loaded source is: its file's content and, when it has one, its database lens profile
|
||||
/// ([`lens_key`]; the decode fits the starting colour through it). A job's source is kept only when this still
|
||||
/// matches when the job finishes ([`super::Session::accept`]), so a decode started before a profile change never
|
||||
/// returns to the cache after `cmd::lens_refresh` has dropped the photo's sources.
|
||||
pub fn source_identity(p: &Photo) -> Hash128 {
|
||||
let mut k = Hasher128::new();
|
||||
k.str(&content_key(p));
|
||||
let lens = lens_key(p);
|
||||
if lens != 0 {
|
||||
k.u64(lens);
|
||||
}
|
||||
k.finish()
|
||||
}
|
||||
|
||||
pub fn content_key(p: &Photo) -> String {
|
||||
match (&p.content_hash, &p.source) {
|
||||
(Some(h), _) => h.clone(),
|
||||
@@ -107,6 +121,21 @@ 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), with the
|
||||
/// correction on or off: a raw's starting colour is fitted through the profile's vignetting either way
|
||||
/// (`camera_preview::starting_colour`), so the decoded picture depends on it. 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) -> u64 {
|
||||
match &p.embedded_lens {
|
||||
Some(l) if 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.)
|
||||
|
||||
@@ -47,11 +47,12 @@ impl crate::Session {
|
||||
// share a result (a view slot showing photo A would otherwise look current for photo B)
|
||||
// …and so is the file's content: a file changed on disk (Reload) renders afresh
|
||||
let content_key = content_key(&p);
|
||||
let source_key = Hasher128::new().str(&content_key).finish();
|
||||
let content = source_key.0 as u64;
|
||||
let content = Hasher128::new().str(&content_key).finish().0 as u64;
|
||||
let source_key = super::quick::source_identity(&p);
|
||||
// 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);
|
||||
let key = settings_hash
|
||||
^ ((max_w as u64) << 40)
|
||||
^ ((max_h as u64) << 20)
|
||||
@@ -59,7 +60,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());
|
||||
@@ -67,6 +69,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 {
|
||||
@@ -96,20 +102,31 @@ impl crate::Session {
|
||||
pub fn preview_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool, settings: &DevelopSettings) -> Option<RenderJob> {
|
||||
let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?;
|
||||
job.settings = Arc::new(settings.clone());
|
||||
job.key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (job.level as u64) << 60;
|
||||
// (the photo's database lens profile stays part of it: a profile looked up again renders afresh)
|
||||
let lens = self.catalog.photo(id).map_or(0, |p| super::quick::lens_key(p));
|
||||
job.key = settings.hash64()
|
||||
^ ((max_w as u64) << 40)
|
||||
^ ((max_h as u64) << 20)
|
||||
^ (apply_crop as u64)
|
||||
^ (job.level as u64) << 60
|
||||
^ lens.rotate_left(29);
|
||||
Some(job)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
if lens != 0 {
|
||||
k.u64(lens);
|
||||
}
|
||||
k.finish()
|
||||
}
|
||||
|
||||
/// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset
|
||||
@@ -126,7 +143,7 @@ impl crate::Session {
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: Some(Hasher128::new().str(&content_key(&p)).finish()),
|
||||
source_key: Some(super::quick::source_identity(&p)),
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
@@ -181,7 +198,7 @@ impl crate::Session {
|
||||
Some(RenderJob {
|
||||
request_id: 0,
|
||||
cache_generation: self.media.rendered.generation(),
|
||||
source_key: Some(Hasher128::new().str(&content_key(&p)).finish()),
|
||||
source_key: Some(super::quick::source_identity(&p)),
|
||||
photo: id,
|
||||
level,
|
||||
source,
|
||||
@@ -216,16 +233,21 @@ impl crate::Session {
|
||||
Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge })
|
||||
}
|
||||
|
||||
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
|
||||
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
|
||||
Hasher128::new()
|
||||
.str(&content_key(p))
|
||||
/// Size-independent cache key of a photo's view render (loupe) for its current settings, its lens profile
|
||||
/// included as in [`Self::variant_key`].
|
||||
pub(crate) fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
|
||||
let mut k = Hasher128::new();
|
||||
k.str(&content_key(p))
|
||||
.str("view")
|
||||
.u64(p.develop.hash64())
|
||||
.u64(apply_crop 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);
|
||||
if lens != 0 {
|
||||
k.u64(lens);
|
||||
}
|
||||
k.finish()
|
||||
}
|
||||
|
||||
/// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the
|
||||
@@ -374,7 +396,7 @@ impl crate::Session {
|
||||
/// has wrong is corrected too ([`Self::learn_as_shot_wb`]).
|
||||
pub fn accept(&mut self, r: &RenderResult) {
|
||||
if let Some(src) = &r.loaded
|
||||
&& r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key))
|
||||
&& r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| super::quick::source_identity(p) == key))
|
||||
{
|
||||
if let Some(info) = &src.info {
|
||||
self.learn_as_shot_wb(r.photo, info);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo};
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 73;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 74;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
@@ -183,14 +183,35 @@ 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 database profile (`catalog`, [`lightcraft_catalog::Photo::embedded_lens`]). A file correction the
|
||||
/// catalog still holds but the decode no longer finds (the original was replaced) is not restored. 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.filter(|c| c.source == lightcraft_develop::LensSource::Database),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SourceRef {
|
||||
Loaded(Box<DecodedSource>),
|
||||
|
||||
@@ -88,6 +88,44 @@ fn source_from_before_content_reload_is_not_accepted() {
|
||||
s.accept(&old);
|
||||
assert_eq!(s.media.source_usage().0, 0);
|
||||
}
|
||||
/// A decode that started before the photo's database lens profile changed (a lens database arriving, looked up again
|
||||
/// by `cmd::lens_refresh`, which drops the photo's sources) never puts its source back in the cache: the source's
|
||||
/// colour was fitted through the old profile.
|
||||
#[test]
|
||||
fn source_from_before_a_lens_profile_change_is_not_accepted() {
|
||||
let mut s = session();
|
||||
s.media.file_loader = Some(Arc::new(|_, _| Ok((Rgb32f::new(8, 8), SourceInfo::default()))));
|
||||
let old = s.thumb_job(PhotoId(1), 256).unwrap().run();
|
||||
assert!(old.loaded.is_some());
|
||||
let lens = lightcraft_develop::EmbeddedLens { source: lightcraft_develop::LensSource::Database, ..Default::default() };
|
||||
s.catalog.apply(Op::SetEmbeddedLens { id: PhotoId(1), lens: Some(Box::new(lens)) }).unwrap();
|
||||
s.media.forget(PhotoId(1));
|
||||
s.accept(&old);
|
||||
assert_eq!(s.media.source_usage().0, 0, "the old decode is dropped");
|
||||
// a decode made with the new profile is kept
|
||||
let new = s.thumb_job(PhotoId(1), 256).unwrap().run();
|
||||
s.accept(&new);
|
||||
assert_ne!(s.media.source_usage().0, 0);
|
||||
}
|
||||
/// The loupe's hover preview (a preset or profile under the pointer) is cached by its key, so a database lens
|
||||
/// profile looked up again gives it a new key and the hover renders afresh instead of showing the old correction.
|
||||
#[test]
|
||||
fn a_hover_preview_key_follows_the_lens_profile() {
|
||||
let mut s = session();
|
||||
let settings = s.catalog.photo(PhotoId(1)).unwrap().develop.clone();
|
||||
let before = s.preview_job(PhotoId(1), 640, 480, true, &settings).unwrap().key;
|
||||
let lens = lightcraft_develop::EmbeddedLens { source: lightcraft_develop::LensSource::Database, ..Default::default() };
|
||||
s.catalog.apply(Op::SetEmbeddedLens { id: PhotoId(1), lens: Some(Box::new(lens)) }).unwrap();
|
||||
let after = s.preview_job(PhotoId(1), 640, 480, true, &settings).unwrap().key;
|
||||
assert_ne!(before, after);
|
||||
// one database profile looked up again as another (a newer database): the key follows the profile's contents,
|
||||
// not only whether there is one
|
||||
let vignette = lightcraft_develop::EmbeddedVignette { k: [-0.3, 0.0, 0.0, 0.0, 0.0], radius: 1.0, ..Default::default() };
|
||||
let other = lightcraft_develop::EmbeddedLens { source: lightcraft_develop::LensSource::Database, vignette: Some(vignette), ..Default::default() };
|
||||
s.catalog.apply(Op::SetEmbeddedLens { id: PhotoId(1), lens: Some(Box::new(other)) }).unwrap();
|
||||
let again = s.preview_job(PhotoId(1), 640, 480, true, &settings).unwrap().key;
|
||||
assert_ne!(after, again, "a different database profile");
|
||||
}
|
||||
/// A panic while rendering (here in the decoder) used to end the pool's worker thread without
|
||||
/// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now,
|
||||
/// for renders and for stand-ins, answering the request it came from.
|
||||
|
||||
@@ -46,6 +46,9 @@ pub(crate) trait MoveFs {
|
||||
fn packet_version(&self, path: &Path) -> std::io::Result<PacketVersion> {
|
||||
use std::io::Read;
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(std::io::Error::other(format!("{} is not a regular file", path.display())));
|
||||
}
|
||||
let mut hash = lightcraft_preview::Hasher128::new();
|
||||
let mut len = 0u64;
|
||||
let mut buffer = [0u8; 16 * 1024];
|
||||
@@ -141,8 +144,14 @@ impl MoveFs for RealFs {
|
||||
.map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id())))
|
||||
.find(|p| !self.exists(p))
|
||||
.ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?;
|
||||
// a sidecar found beside the photo by its name is copied only when it is a plain file: a
|
||||
// link there would have its target's contents copied into a new, readable file
|
||||
let checked = std::fs::symlink_metadata(a)?;
|
||||
if !checked.is_file() {
|
||||
return Err(std::io::Error::other(format!("{} is not a regular file", a.display())));
|
||||
}
|
||||
let copied = (|| {
|
||||
let mut from = std::fs::File::open(a)?;
|
||||
let mut from = open_regular(a, &checked)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?;
|
||||
std::io::copy(&mut from, &mut f)?;
|
||||
f.sync_all()
|
||||
@@ -163,6 +172,33 @@ impl MoveFs for RealFs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `a` for reading and checks the open handle is the regular file `checked` (the
|
||||
/// `symlink_metadata` of `a` taken before): the path check alone can be outrun by a swap for a
|
||||
/// link, whose target the open would follow.
|
||||
fn open_regular(a: &Path, checked: &std::fs::Metadata) -> std::io::Result<std::fs::File> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
// FILE_FLAG_OPEN_REPARSE_POINT: a symbolic link is opened itself, and then refused below
|
||||
#[cfg(windows)]
|
||||
std::os::windows::fs::OpenOptionsExt::custom_flags(&mut options, 0x0020_0000);
|
||||
let file = options.open(a)?;
|
||||
let opened = file.metadata()?;
|
||||
#[cfg(unix)]
|
||||
let same = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
(opened.dev(), opened.ino()) == (checked.dev(), checked.ino())
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let same = {
|
||||
let _ = checked;
|
||||
true
|
||||
};
|
||||
if !opened.is_file() || !same {
|
||||
return Err(std::io::Error::other(format!("{} is not a regular file", a.display())));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original
|
||||
/// byte for byte, and only then remove the original. A copy that doesn't match is removed.
|
||||
fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
@@ -240,3 +276,25 @@ pub(super) fn move_one(fs: &dyn MoveFs, a: &Path, b: &Path) -> std::io::Result<(
|
||||
Err(e2) => std::io::Error::other(format!("{e}; it could not be given its old name back either ({e2}): it is now {}", tmp.display())),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod open_regular_tests {
|
||||
use super::open_regular;
|
||||
|
||||
#[test]
|
||||
fn a_link_swapped_in_after_the_check_is_not_followed() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-open-regular-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (file, other, link) = (dir.join("a.xmp"), dir.join("secret"), dir.join("link.xmp"));
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
std::fs::write(&other, b"secret").unwrap();
|
||||
let checked = std::fs::symlink_metadata(&file).unwrap();
|
||||
assert!(open_regular(&file, &checked).is_ok());
|
||||
// the name now holds a link to another file: the handle is not the one checked
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
std::os::unix::fs::symlink(&other, &link).unwrap();
|
||||
std::fs::rename(&link, &file).unwrap();
|
||||
assert!(open_regular(&file, &checked).is_err(), "the link's target must not be opened");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn plan_rename_photos(photos: &[std::sync::Arc<Photo>], template: &str, star
|
||||
|
||||
/// Plan renaming `photos`: `exists` says whether something answers to a path, `same_file` whether
|
||||
/// two spellings name one file (see [`MoveFs`]).
|
||||
fn plan_rename_core(
|
||||
pub(super) fn plan_rename_core(
|
||||
photos: &[std::sync::Arc<Photo>],
|
||||
template: &str,
|
||||
start: usize,
|
||||
@@ -35,6 +35,9 @@ fn plan_rename_core(
|
||||
same_file: &dyn Fn(&Path, &Path) -> bool,
|
||||
) -> Vec<RenamePlan> {
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
// Per first-choice target: how many leading suffixes are all claimed in this batch (`taken` only grows), so the
|
||||
// next photo with the same first choice starts after them instead of rebuilding each one.
|
||||
let mut claimed_prefix: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
for (i, p) in photos.iter().enumerate() {
|
||||
let seq = start.saturating_add(i);
|
||||
@@ -53,7 +56,9 @@ fn plan_rename_core(
|
||||
let (to, to_path) = match &p.source {
|
||||
Source::File { path } => {
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut k = 0;
|
||||
let base = folded_path(&dir.join(&want).to_string_lossy());
|
||||
let mut k = claimed_prefix.get(&base).copied().unwrap_or(0);
|
||||
let mut all_taken = true;
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
let tp = dir.join(&name).to_string_lossy().to_string();
|
||||
@@ -64,18 +69,25 @@ fn plan_rename_core(
|
||||
let same = from == to || (key == folded_path(path) && exists(to) && same_file(from, to));
|
||||
// free: not claimed in this batch and not on disk (unless it is this very file).
|
||||
// A file this batch moves away still counts as taken: simple and safe.
|
||||
if !taken.contains(&key) && (same || !exists(to)) {
|
||||
let claimed = taken.contains(&key);
|
||||
if !claimed && (same || !exists(to)) {
|
||||
taken.insert(key);
|
||||
if all_taken {
|
||||
claimed_prefix.insert(base, k + 1);
|
||||
}
|
||||
break (name, Some(tp));
|
||||
}
|
||||
all_taken &= claimed;
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
Source::Demo { .. } => {
|
||||
let mut k = 0;
|
||||
let base = format!("demo:{}", want.to_lowercase());
|
||||
let mut k = claimed_prefix.get(&base).copied().unwrap_or(0);
|
||||
loop {
|
||||
let name = candidate(k);
|
||||
if taken.insert(format!("demo:{}", name.to_lowercase())) {
|
||||
claimed_prefix.insert(base, k + 1); // (every candidate before it was claimed: nothing but `taken` rejects a demo name)
|
||||
break (name, None);
|
||||
}
|
||||
k += 1;
|
||||
|
||||
@@ -588,3 +588,37 @@ fn number_folder_and_metadata_tokens() {
|
||||
// a bare {seq} is padded to the caller's width
|
||||
assert_eq!(expand_tokens("{seq}|{seq:2}", &p, 7, 3), "007|07");
|
||||
}
|
||||
|
||||
/// Many photos wanting one name take its suffixes in order without each starting over at the bare name.
|
||||
#[test]
|
||||
fn photos_sharing_a_target_name_do_not_rescan_the_suffixes_already_claimed() {
|
||||
let photos: Vec<std::sync::Arc<Photo>> = (0..300)
|
||||
.map(|i| {
|
||||
std::sync::Arc::new(Photo::new(
|
||||
PhotoId(i + 1),
|
||||
Source::File { path: format!("/lib/src{i}.jpg") },
|
||||
&format!("src{i}.jpg"),
|
||||
"JPEG",
|
||||
0,
|
||||
0,
|
||||
"2024-01-01T00:00:00Z",
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let checks = std::cell::Cell::new(0usize);
|
||||
let plans = plan::plan_rename_core(
|
||||
&photos,
|
||||
"same",
|
||||
1,
|
||||
&|_| {
|
||||
checks.set(checks.get() + 1);
|
||||
false
|
||||
},
|
||||
&|_, _| false,
|
||||
);
|
||||
let mut names: Vec<&str> = plans.iter().map(|p| p.to.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), 300);
|
||||
assert!(checks.get() <= 3 * 300, "{} disk checks", checks.get());
|
||||
}
|
||||
|
||||
@@ -379,12 +379,23 @@ fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The key and settings of photo `id` as the model sees it: its look without masks, uncropped.
|
||||
/// The key and settings of photo `id` as the model sees it: its look without masks, uncropped
|
||||
/// and unflipped, and without lens corrections, Upright and Transform. Mask coordinates
|
||||
/// (clicks, stored selections) live on the photo before those (`Mask::on_photo`), so a
|
||||
/// selection stays on its content when they change, and changing them doesn't ask again.
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
fn segment_key(&self, id: PhotoId) -> Option<(u64, lightcraft_develop::DevelopSettings)> {
|
||||
pub(crate) fn segment_key(&self, id: PhotoId) -> Option<(u64, lightcraft_develop::DevelopSettings)> {
|
||||
let p = self.catalog.photo(id)?;
|
||||
let mut d = (*p.develop).clone();
|
||||
d.masks.clear();
|
||||
// (the crop doesn't show in the uncropped input: it doesn't ask again either)
|
||||
d.crop = Default::default();
|
||||
d.geometry = Default::default();
|
||||
d.optics.lens_profile = false;
|
||||
d.optics.distortion = 0.0;
|
||||
d.optics.remove_ca = false;
|
||||
d.optics.ca_red = 0.0;
|
||||
d.optics.ca_blue = 0.0;
|
||||
let content = crate::media::content_key(p);
|
||||
let mut h = mix(0xcbf2_9ce4_8422_2325, d.hash64());
|
||||
h = mix(h, id.0);
|
||||
@@ -413,6 +424,13 @@ impl Session {
|
||||
region: Option<[f64; 4]>,
|
||||
) -> Result<worker::Job, String> {
|
||||
let dir = self.segmenter.model_dir()?;
|
||||
// old masks go onto the photo first: the selection that comes back is in its coordinates
|
||||
if let Some(cur) = self.develop_of(id).filter(|d| d.masks.iter().any(|m| !m.on_photo)) {
|
||||
let mut on = (*cur).clone();
|
||||
self.attach_masks(id, &cur, &mut on);
|
||||
self.apply_silent(lightcraft_catalog::Op::SetDevelop { id, settings: std::sync::Arc::new(on), label: "Masks".into(), edited: None })
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let (key, mut d) = self.segment_key(id).ok_or("no such photo")?;
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
let missing = match &p.source {
|
||||
|
||||
@@ -455,6 +455,11 @@ fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
/// Copy `path` to a new `<name>.bak-<stamp>` (`-1`, `-2`… if taken; never overwriting).
|
||||
fn backup(path: &Path, stamp: &str) -> std::io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
// only a plain file is copied aside: a link under the sidecar's name would have its target's
|
||||
// contents copied into a new, readable file
|
||||
if !std::fs::symlink_metadata(path)?.is_file() {
|
||||
return Err(std::io::Error::other(format!("{} is not a regular file", path.display())));
|
||||
}
|
||||
let bytes = std::fs::read(path)?;
|
||||
let stamp: String = stamp.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
|
||||
@@ -389,7 +389,10 @@ impl PartFile {
|
||||
let mut name = target.file_name().ok_or_else(|| format!("{}: not a file name", target.display()))?.to_os_string();
|
||||
name.push(".part");
|
||||
let part = target.with_file_name(name);
|
||||
let f = std::fs::File::create(&part).map_err(|e| format!("{}: {e}", part.display()))?;
|
||||
// an entry already under the staging name (a leftover, or a link planted to point the
|
||||
// encoder's bytes at another file) is removed, never opened and written through
|
||||
let _ = std::fs::remove_file(&part);
|
||||
let f = std::fs::OpenOptions::new().write(true).create_new(true).open(&part).map_err(|e| format!("{}: {e}", part.display()))?;
|
||||
Ok((PartFile { part, target: target.to_path_buf(), done: false }, f))
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +268,15 @@ pub fn check_writable(dir: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
||||
}
|
||||
let probe = dir.join(format!(".lightcraft-write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"x").map_err(|e| format!("{} is not writable (is the drive full or read-only?): {e}", dir.display()))?;
|
||||
// the probe is a new entry of ours: an existing one (a link planted under the name would be
|
||||
// written through) is removed first, never opened
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&probe)
|
||||
.and_then(|mut f| std::io::Write::write_all(&mut f, b"x"))
|
||||
.map_err(|e| format!("{} is not writable (is the drive full or read-only?): {e}", dir.display()))?;
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -18,6 +18,21 @@ fn writable_check_does_not_create_missing_parents() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_probe_never_writes_through_a_planted_link() {
|
||||
let base = temp("probe-link");
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let victim = base.join("victim");
|
||||
std::fs::write(&victim, b"keep").unwrap();
|
||||
let probe = base.join(format!(".lightcraft-write-test-{}", std::process::id()));
|
||||
std::os::unix::fs::symlink(&victim, &probe).unwrap();
|
||||
assert!(check_writable(&base).is_ok());
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
|
||||
assert!(!probe.exists());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_moves_leaves_or_discards() {
|
||||
let base = temp("migrate");
|
||||
|
||||
@@ -334,8 +334,9 @@ fn match_moved(
|
||||
) -> HashMap<String, String> {
|
||||
// distinct missing files (a photo's virtual copies share its file), with what is known of them
|
||||
let mut files: Vec<(&str, Option<&Photo>)> = Vec::new();
|
||||
let mut seen_paths: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for m in missing {
|
||||
if !files.iter().any(|(p, _)| *p == m.path) {
|
||||
if seen_paths.insert(m.path.as_str()) {
|
||||
files.push((m.path.as_str(), labels.photo(PhotoId(m.id)).map(|p| &**p)));
|
||||
}
|
||||
}
|
||||
@@ -363,7 +364,8 @@ fn match_moved(
|
||||
}
|
||||
}
|
||||
let mut found: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new();
|
||||
for c in candidates.iter().filter(usable).filter(|c| !moved.values().any(|to| *to == c.path)) {
|
||||
let taken: std::collections::HashSet<&str> = moved.values().map(String::as_str).collect();
|
||||
for c in candidates.iter().filter(usable).filter(|c| !taken.contains(c.path.as_str())) {
|
||||
if let Some(k) = key(c.file_size, c.width, c.height) {
|
||||
found.entry(k).or_default().push(c.path.as_str());
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ mod ids_params;
|
||||
mod import;
|
||||
mod import_move;
|
||||
mod keyword_list;
|
||||
mod lens_refresh;
|
||||
mod libops;
|
||||
mod library;
|
||||
mod lightroom_folder;
|
||||
mod mask_effects;
|
||||
mod mask_geometry;
|
||||
mod merge;
|
||||
mod move_to_share;
|
||||
mod organize;
|
||||
|
||||
@@ -701,3 +701,18 @@ fn a_destination_without_write_permission_reports_an_error_and_keeps_everything(
|
||||
}
|
||||
std::fs::set_permissions(dir.0.join("ro"), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_library_folder_is_protected_however_its_path_is_spelled() {
|
||||
let dir = Scratch::new("library-link");
|
||||
let lib = dir.0.join("library");
|
||||
let mut s = Session::new();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
// a link to the library's folder names the same folder on disk
|
||||
std::os::unix::fs::symlink(&lib, dir.0.join("alias")).unwrap();
|
||||
std::fs::create_dir_all(dir.0.join("elsewhere")).unwrap();
|
||||
let err = s.execute("folder.move", &json!({"path": dir.path("alias"), "into": dir.path("elsewhere")})).unwrap_err().to_string();
|
||||
assert!(err.contains("library's own folders"), "{err}");
|
||||
assert!(lib.is_dir() && !dir.0.join("elsewhere/alias").exists());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
//! 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 batch of results gives each probed photo's profile to that photo and to its own virtual copies, never to the
|
||||
/// copies of a photo that wasn't probed.
|
||||
#[test]
|
||||
fn each_result_reaches_its_photos_virtual_copies_and_no_others() {
|
||||
let mut s = Session::new();
|
||||
for p in [raw(1, "50mm F1.8", false), raw(2, "50mm F1.8", false), raw(3, "50mm F1.8", false)] {
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
for (id, of) in [(11, 1), (12, 1), (21, 2), (31, 3)] {
|
||||
let mut copy = raw(id, "50mm F1.8", false);
|
||||
copy.copy_of = Some(PhotoId(of));
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(copy) }).unwrap();
|
||||
}
|
||||
let probed = [1, 2].map(|id| {
|
||||
let m = crate::cmd::lens_refresh::Missing { id: PhotoId(id), path: format!("/photos/{id}.cr3") };
|
||||
(m, Ok(crate::media::ProbeInfo { embedded_lens: Some(lens(-0.01)), ..Default::default() }))
|
||||
});
|
||||
let mut found = crate::cmd::lens_refresh::apply(&mut s, probed.into()).unwrap();
|
||||
found.sort();
|
||||
assert_eq!(found, [1, 2, 11, 12, 21].map(PhotoId), "each probed photo and its own copies");
|
||||
for id in [1, 2, 11, 12, 21] {
|
||||
assert!(s.catalog.photo(PhotoId(id)).unwrap().embedded_lens.is_some(), "photo {id} gets the profile");
|
||||
}
|
||||
for id in [3, 31] {
|
||||
assert!(s.catalog.photo(PhotoId(id)).unwrap().embedded_lens.is_none(), "photo {id} wasn't probed: unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
// the original was replaced by one without a correction: the decode finds none, and the catalog's old file
|
||||
// correction isn't put back
|
||||
let none = DecodedSource::new(img.clone(), Some(lightcraft_pipeline::SourceInfo { lens: None, ..Default::default() }));
|
||||
assert_eq!(none.info_or(header(Some(file_lens))).lens, None, "no file correction the file no longer has");
|
||||
assert_eq!(none.info_or(header(Some(lens(-0.01)))).lens, Some(lens(-0.01)), "the database profile stays");
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// they change too: the decode fits the raw's starting colour through the profile's vignetting either way.
|
||||
#[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), Session::view_key(&p, true))
|
||||
};
|
||||
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");
|
||||
assert_ne!(before.3, after.3, "loupe view cache key");
|
||||
// correction off: the starting colour's fit still uses the profile, so the keys change as well
|
||||
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();
|
||||
let off_after = keys(&mut s);
|
||||
assert_ne!(off_before.0, off_after.0, "render key, correction off");
|
||||
assert_ne!(off_before.1, off_after.1, "thumbnail cache key, correction off");
|
||||
assert_ne!(off_before.2, off_after.2, "variant cache key, correction off");
|
||||
assert_ne!(off_before.3, off_after.3, "loupe view cache key, correction off");
|
||||
}
|
||||
|
||||
/// `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);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Masks stay on the photo's content when its geometry changes: Rotate
|
||||
//! Left / Right turns them with the photo, an old mask (on the corrected frame) goes onto the
|
||||
//! photo at its next edit without moving, and the AI models see the photo the masks live on
|
||||
//! (no lens corrections, Upright, Transform, crop or flips), so changing those leaves the AI
|
||||
//! selections as they are.
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, Mask, MaskComponent, MaskOp, MaskShape};
|
||||
use lightcraft_geom::Point;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn active_dev(s: &Session) -> DevelopSettings {
|
||||
(*s.develop_of(s.active().unwrap()).unwrap()).clone()
|
||||
}
|
||||
|
||||
fn radial_at(p: Point) -> Mask {
|
||||
Mask {
|
||||
id: 1,
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial { center: p, rx: 0.1, ry: 0.05, angle: 20.0, feather: 50.0, invert: false },
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn centre(d: &DevelopSettings) -> (Point, f64) {
|
||||
match &d.masks[0].components[0].shape {
|
||||
MaskShape::Radial { center, angle, .. } => (*center, *angle),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_the_photo_turns_its_masks_with_it() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut d = active_dev(&s);
|
||||
let p = Point::new(0.2, 0.3);
|
||||
d.masks = vec![radial_at(p)];
|
||||
s.set_develop(id, d, "Mask").unwrap();
|
||||
s.execute("photo.rotateRight", &json!({})).unwrap();
|
||||
let (c, a) = centre(&active_dev(&s));
|
||||
// a quarter turn clockwise: (x, y) → (1 − y, x), and the ellipse turns along
|
||||
assert!((c.x - 0.7).abs() < 1e-9 && (c.y - 0.2).abs() < 1e-9, "{c:?}");
|
||||
assert_eq!(a, 110.0);
|
||||
s.execute("photo.rotateLeft", &json!({})).unwrap();
|
||||
let (c, a) = centre(&active_dev(&s));
|
||||
assert!((c.x - p.x).abs() < 1e-9 && (c.y - p.y).abs() < 1e-9 && a == 20.0, "{c:?} {a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_mask_goes_onto_the_photo_at_its_next_edit_without_moving() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut d = active_dev(&s);
|
||||
d.geometry.vertical = 30.0;
|
||||
d.geometry.rotate = 5.0;
|
||||
let p = Point::new(0.3, 0.4);
|
||||
let mut m = radial_at(p);
|
||||
m.on_photo = false;
|
||||
d.masks = vec![m];
|
||||
// as a catalog from before keeps it
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetDevelop { id, settings: std::sync::Arc::new(d.clone()), label: "Old".into(), edited: None }).unwrap();
|
||||
assert!(!active_dev(&s).masks[0].on_photo);
|
||||
d.light.exposure = 0.5;
|
||||
s.set_develop(id, d.clone(), "Exposure").unwrap();
|
||||
let now = active_dev(&s);
|
||||
assert!(now.masks[0].on_photo, "attached at the edit");
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
let frame = lightcraft_pipeline::geometry::Frame::with_lens(photo.width as usize, photo.height as usize, &d, false, None);
|
||||
let (c, _) = centre(&now);
|
||||
let back = frame.for_mask(true).mask_to_norm(c);
|
||||
assert!((back.x - p.x).abs() < 1e-6 && (back.y - p.y).abs() < 1e-6, "renders where it did: {back:?}");
|
||||
assert!((c.x - p.x).hypot(c.y - p.y) > 0.005, "its place on the photo differs from the corrected frame's: {c:?}");
|
||||
// a later Transform change leaves it on the photo where it is
|
||||
let mut d = now.clone();
|
||||
d.geometry.vertical = -10.0;
|
||||
s.set_develop(id, d, "Transform").unwrap();
|
||||
assert_eq!(centre(&active_dev(&s)).0, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ai_models_see_the_photo_the_masks_live_on() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let key = s.segment_key(id).unwrap().0;
|
||||
let mut d = active_dev(&s);
|
||||
d.geometry.vertical = 20.0;
|
||||
d.optics.distortion = 30.0;
|
||||
d.crop.geometry.angle = 5.0;
|
||||
d.crop.flip_h = true;
|
||||
s.set_develop(id, d.clone(), "Geometry").unwrap();
|
||||
assert_eq!(s.segment_key(id).unwrap().0, key, "geometry doesn't change what the models see: the AI masks stay valid");
|
||||
d.light.exposure = 1.0;
|
||||
s.set_develop(id, d, "Exposure").unwrap();
|
||||
assert_ne!(s.segment_key(id).unwrap().0, key, "the look still does");
|
||||
}
|
||||
|
||||
/// A mask saved before `Mask::on_photo` reads as an old mask (on the corrected frame), a new mask
|
||||
/// is on the photo, and an old one is written as it was read.
|
||||
#[test]
|
||||
fn a_mask_saved_before_reads_as_an_old_mask() {
|
||||
let old: Mask = serde_json::from_value(json!({"id": 3, "name": "Mask 3", "components": []})).unwrap();
|
||||
assert!(!old.on_photo);
|
||||
assert!(Mask::default().on_photo);
|
||||
let written = serde_json::to_value(&old).unwrap();
|
||||
assert!(written.get("on_photo").is_none(), "{written}");
|
||||
assert_eq!(serde_json::to_value(Mask::default()).unwrap()["on_photo"], json!(true));
|
||||
}
|
||||
|
||||
/// Old masks an edit brings in (paste, sync, a preset, a history step, a sidecar) go onto the
|
||||
/// photo from the settings they came with, where they render now, not from the photo's settings
|
||||
/// before the edit (review of #3550).
|
||||
#[test]
|
||||
fn old_masks_an_edit_brings_in_go_onto_the_photo_from_the_settings_they_came_with() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
// the photo now: no Transform
|
||||
let before = active_dev(&s);
|
||||
// what comes in: a Transform and an old mask drawn under it
|
||||
let p = Point::new(0.3, 0.4);
|
||||
let mut incoming = before.clone();
|
||||
incoming.geometry.vertical = 30.0;
|
||||
incoming.geometry.rotate = 5.0;
|
||||
let mut m = radial_at(p);
|
||||
m.on_photo = false;
|
||||
incoming.masks = vec![m];
|
||||
s.set_develop(id, incoming.clone(), "Paste Settings").unwrap();
|
||||
let now = active_dev(&s);
|
||||
assert!(now.masks[0].on_photo);
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
let frame = lightcraft_pipeline::geometry::Frame::with_lens(photo.width as usize, photo.height as usize, &incoming, false, None);
|
||||
let back = frame.for_mask(true).mask_to_norm(centre(&now).0);
|
||||
assert!((back.x - p.x).abs() < 1e-6 && (back.y - p.y).abs() < 1e-6, "renders where it did under its own settings: {back:?}");
|
||||
}
|
||||
|
||||
/// A click on an old Object mask goes onto the photo with the mask before the model reads it: the
|
||||
/// stored clicks and the new one are then in the same coordinates (review of #3550).
|
||||
#[test]
|
||||
fn a_click_on_an_old_object_mask_goes_onto_the_photo_with_it() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut d = active_dev(&s);
|
||||
d.geometry.vertical = 30.0;
|
||||
d.geometry.rotate = 5.0;
|
||||
let p = Point::new(0.3, 0.4);
|
||||
d.masks = vec![Mask {
|
||||
id: 1,
|
||||
on_photo: false,
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Object { hint: vec![p], exclude: Vec::new(), seg: None, detail: Vec::new(), edge: 0.0 },
|
||||
}],
|
||||
..Default::default()
|
||||
}];
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetDevelop { id, settings: std::sync::Arc::new(d.clone()), label: "Old".into(), edited: None }).unwrap();
|
||||
s.active_mask = Some(1);
|
||||
// (no model: the command fails at the model, after the mask went onto the photo)
|
||||
s.segmenter.dir = Some(std::env::temp_dir().join("lightcraft-no-model-mask-geometry"));
|
||||
let _ = s.execute("mask.objectPoint", &json!({"x": 0.6, "y": 0.5}));
|
||||
let now = active_dev(&s);
|
||||
assert!(now.masks[0].on_photo);
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
let frame = lightcraft_pipeline::geometry::Frame::with_lens(photo.width as usize, photo.height as usize, &d, false, None);
|
||||
let MaskShape::Object { hint, .. } = &now.masks[0].components[0].shape else { panic!() };
|
||||
let want = frame.for_mask(true).norm_to_mask(p);
|
||||
assert!((hint[0].x - want.x).abs() < 1e-9 && (hint[0].y - want.y).abs() < 1e-9, "{hint:?} vs {want:?}");
|
||||
}
|
||||
|
||||
/// An old mask the photo keeps, when an edit both brings in a mask and changes the geometry (a
|
||||
/// preset), goes onto the photo from the photo's settings before the edit, where it rendered; the
|
||||
/// mask brought in, from the settings it came with (review of #3550).
|
||||
#[test]
|
||||
fn a_kept_old_mask_attaches_under_the_settings_before_an_edit_that_adds_one() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut d = active_dev(&s);
|
||||
d.geometry.vertical = 30.0;
|
||||
d.geometry.rotate = 5.0;
|
||||
let p = Point::new(0.3, 0.4);
|
||||
let mut kept = radial_at(p);
|
||||
kept.on_photo = false;
|
||||
d.masks = vec![kept];
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetDevelop { id, settings: std::sync::Arc::new(d.clone()), label: "Old".into(), edited: None }).unwrap();
|
||||
// the preset: another Transform and a new old mask
|
||||
let mut incoming = d.clone();
|
||||
incoming.geometry.vertical = -20.0;
|
||||
incoming.geometry.rotate = 0.0;
|
||||
let q = Point::new(0.6, 0.6);
|
||||
let mut added = radial_at(q);
|
||||
added.id = 2;
|
||||
added.on_photo = false;
|
||||
incoming.masks.push(added);
|
||||
s.set_develop(id, incoming.clone(), "Preset").unwrap();
|
||||
let now = active_dev(&s);
|
||||
assert!(now.masks.iter().all(|m| m.on_photo));
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
let frame = |d: &DevelopSettings| lightcraft_pipeline::geometry::Frame::with_lens(photo.width as usize, photo.height as usize, d, false, None);
|
||||
let at = |m: &Mask| match &m.components[0].shape {
|
||||
MaskShape::Radial { center, .. } => *center,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let back = frame(&d).for_mask(true).mask_to_norm(at(&now.masks[0]));
|
||||
assert!((back.x - p.x).abs() < 1e-6 && (back.y - p.y).abs() < 1e-6, "the kept mask, from the frame it was drawn on: {back:?}");
|
||||
let back = frame(&incoming).for_mask(true).mask_to_norm(at(&now.masks[1]));
|
||||
assert!((back.x - q.x).abs() < 1e-6 && (back.y - q.y).abs() < 1e-6, "the added mask, from its own: {back:?}");
|
||||
}
|
||||
|
||||
/// A sync or paste of the Crop group without the Masks group turns the photo: its masks turn with
|
||||
/// it, as Rotate does (review of #3550).
|
||||
#[test]
|
||||
fn masks_turn_with_an_orientation_a_sync_brings() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut d = active_dev(&s);
|
||||
let p = Point::new(0.2, 0.3);
|
||||
d.masks = vec![radial_at(p)];
|
||||
s.set_develop(id, d.clone(), "Mask").unwrap();
|
||||
let mut synced = d.clone();
|
||||
synced.orientation = d.orientation.rotated(true);
|
||||
s.set_develop(id, synced, "Sync Settings").unwrap();
|
||||
let (c, a) = centre(&active_dev(&s));
|
||||
assert!((c.x - 0.7).abs() < 1e-9 && (c.y - 0.2).abs() < 1e-9 && a == 110.0, "{c:?} {a}");
|
||||
// Rotate itself still turns them once
|
||||
s.execute("photo.rotateLeft", &json!({})).unwrap();
|
||||
let (c, a) = centre(&active_dev(&s));
|
||||
assert!((c.x - p.x).abs() < 1e-9 && (c.y - p.y).abs() < 1e-9 && a == 20.0, "{c:?} {a}");
|
||||
}
|
||||
|
||||
/// The change between two orientations is the turn Rotate makes, and none between equal ones.
|
||||
#[test]
|
||||
fn the_change_between_orientations_is_the_turn_made() {
|
||||
use lightcraft_geom::Orientation;
|
||||
for k in 0..8u8 {
|
||||
let o = Orientation::from_parts(k >= 4, k % 4);
|
||||
assert_eq!(crate::orientation_change(o, o), None);
|
||||
for cw in [true, false] {
|
||||
assert_eq!(crate::orientation_change(o, o.rotated(cw)), Some(Orientation::Normal.rotated(cw)), "{o:?} {cw}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An old Object mask with a Transform active, and a click.
|
||||
fn old_object_mask() -> (Session, lightcraft_catalog::PhotoId, DevelopSettings, Point) {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut d = active_dev(&s);
|
||||
d.geometry.vertical = 30.0;
|
||||
d.geometry.rotate = 5.0;
|
||||
let p = Point::new(0.3, 0.4);
|
||||
d.masks = vec![Mask {
|
||||
id: 1,
|
||||
on_photo: false,
|
||||
components: vec![MaskComponent {
|
||||
name: None,
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Object { hint: vec![p], exclude: Vec::new(), seg: None, detail: Vec::new(), edge: 0.0 },
|
||||
}],
|
||||
..Default::default()
|
||||
}];
|
||||
s.catalog.apply(lightcraft_catalog::Op::SetDevelop { id, settings: std::sync::Arc::new(d.clone()), label: "Old".into(), edited: None }).unwrap();
|
||||
s.active_mask = Some(1);
|
||||
(s, id, d, p)
|
||||
}
|
||||
|
||||
/// A click on an old Object mask that lands off the photo (the corrected frame's blank space) is
|
||||
/// refused, not moved onto the photo's edge, and leaves the mask as it was (review of #3550).
|
||||
#[test]
|
||||
fn a_click_off_the_photo_on_an_old_object_mask_is_refused() {
|
||||
let (mut s, id, d, _) = old_object_mask();
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
let frame = lightcraft_pipeline::geometry::Frame::with_lens(photo.width as usize, photo.height as usize, &d, false, None);
|
||||
let f = frame.for_mask(true);
|
||||
let off = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (0.5, 0.0), (0.5, 1.0)]
|
||||
.map(|(x, y)| Point::new(x, y))
|
||||
.into_iter()
|
||||
.find(|p| {
|
||||
let n = f.norm_to_mask(*p);
|
||||
!(n.x.is_finite() && n.y.is_finite()) || n.x < -0.01 || n.x > 1.01 || n.y < -0.01 || n.y > 1.01
|
||||
})
|
||||
.expect("a corner of the transformed frame is off the photo");
|
||||
let e = s.execute("mask.objectPoint", &json!({"x": off.x, "y": off.y})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the photo"), "{e}");
|
||||
assert_eq!(active_dev(&s).masks, d.masks, "nothing changed");
|
||||
}
|
||||
|
||||
/// The click on its way to the model is in the mask's coordinates with the stored ones (with a
|
||||
/// model folder that looks installed; skipped where the build has no model support).
|
||||
#[test]
|
||||
fn a_pending_click_on_an_old_object_mask_is_in_the_masks_coordinates() {
|
||||
if !crate::segment::Segmenter::AVAILABLE {
|
||||
lightcraft_testkit::skip!(Model, "built without the segmentation model");
|
||||
return;
|
||||
}
|
||||
let (mut s, id, d, p) = old_object_mask();
|
||||
s.segmenter.background = true;
|
||||
s.segmenter.dir = Some(super::segment::damaged_model("mask-geometry-pending"));
|
||||
let r = s.execute("mask.objectPoint", &json!({"x": 0.6, "y": 0.5})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
let frame = lightcraft_pipeline::geometry::Frame::with_lens(photo.width as usize, photo.height as usize, &d, false, None);
|
||||
let f = frame.for_mask(true);
|
||||
let pending = s.segmenter.pending_clicks().expect("the click is pending").clone();
|
||||
assert_eq!(pending.hint.len(), 2);
|
||||
for (got, want) in pending.hint.iter().zip([f.norm_to_mask(p), f.norm_to_mask(Point::new(0.6, 0.5))]) {
|
||||
assert!((got.x - want.x).abs() < 1e-9 && (got.y - want.y).abs() < 1e-9, "{got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,19 @@ fn other_device(here: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_is_not_copied_as_its_target() {
|
||||
use crate::rename::MoveFs;
|
||||
let dir = Scratch::new("copy-link");
|
||||
std::fs::create_dir_all(dir.0.join("local")).unwrap();
|
||||
std::fs::create_dir_all(dir.0.join("share")).unwrap();
|
||||
std::fs::write(dir.0.join("local/private.txt"), b"secret").unwrap();
|
||||
std::os::unix::fs::symlink(dir.0.join("local/private.txt"), dir.0.join("local/photo.xmp")).unwrap();
|
||||
assert!(crate::rename::RealFs.copy_no_replace(&dir.0.join("local/photo.xmp"), &dir.0.join("share/photo.xmp")).is_err());
|
||||
assert!(!dir.0.join("share/photo.xmp").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_copy_across_volumes_is_whole_or_absent_and_never_replaces() {
|
||||
use crate::rename::MoveFs;
|
||||
|
||||
@@ -17,7 +17,7 @@ fn tmp(name: &str) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
/// A folder that looks installed but holds no usable model.
|
||||
fn damaged_model(name: &str) -> std::path::PathBuf {
|
||||
pub(super) fn damaged_model(name: &str) -> std::path::PathBuf {
|
||||
let d = tmp(name);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(d.join("model.safetensors"), b"\x10\x00\x00\x00\x00\x00\x00\x00{not json at all}").unwrap();
|
||||
|
||||
@@ -54,12 +54,16 @@ fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||
/// best first. Names are compared case-insensitively and shown as first seen.
|
||||
pub fn rank(query: &[f32], gallery: &[(&str, &[f32])]) -> Vec<Ranked> {
|
||||
let mut people: Vec<(String, String, Vec<f32>)> = Vec::new(); // (key, shown name, similarities)
|
||||
let mut at: std::collections::HashMap<String, usize> = std::collections::HashMap::new(); // key -> index in `people`
|
||||
for (name, emb) in gallery {
|
||||
let Some(c) = cosine(query, emb) else { continue };
|
||||
let key = name.to_lowercase();
|
||||
match people.iter_mut().find(|p| p.0 == key) {
|
||||
match at.get(&key).and_then(|&i| people.get_mut(i)) {
|
||||
Some(p) => p.2.push(c),
|
||||
None => people.push((key, (*name).to_string(), vec![c])),
|
||||
None => {
|
||||
at.insert(key.clone(), people.len());
|
||||
people.push((key, (*name).to_string(), vec![c]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Ranked> = people
|
||||
|
||||
@@ -102,6 +102,24 @@ pub struct GeoNames {
|
||||
countries: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Most bytes of one dump file read into memory (the extracted `cities*.txt` is far below this).
|
||||
const MAX_FILE_BYTES: u64 = 256 << 20;
|
||||
|
||||
/// A regular file's text, at most [`MAX_FILE_BYTES`] of it (the opened file is checked, never its pathname).
|
||||
fn read_text(path: &Path) -> std::io::Result<String> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a regular file"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > MAX_FILE_BYTES {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "file too large"));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
fn cell(p: LatLon) -> (i32, i32) {
|
||||
// valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range
|
||||
// longitude 180 is the same meridian as -180: key it as column -180, the wrap `reverse` probes
|
||||
@@ -121,7 +139,8 @@ impl GeoNames {
|
||||
if g.cities.len() >= MAX_PLACES {
|
||||
break;
|
||||
}
|
||||
let f: Vec<&str> = line.split('\t').collect();
|
||||
// only the first 11 columns are used: a line of tabs must not build a field for each
|
||||
let f: Vec<&str> = line.split('\t').take(11).collect();
|
||||
// columns: 1 name, 4 latitude, 5 longitude, 8 country code, 10 admin1 code
|
||||
let (Some(name), Some(lat), Some(lon), Some(cc)) = (f.get(1), f.get(4), f.get(5), f.get(8)) else { continue };
|
||||
let (Ok(lat), Ok(lon)) = (lat.trim().parse::<f64>(), lon.trim().parse::<f64>()) else { continue };
|
||||
@@ -152,7 +171,7 @@ impl GeoNames {
|
||||
/// Load from a folder holding [`GEONAMES_FILES`] (the last two are optional: without them
|
||||
/// states stay blank and countries are ISO codes).
|
||||
pub fn load(dir: &Path) -> Result<GeoNames, GeocodeError> {
|
||||
let read = |n: &str| std::fs::read(dir.join(n)).map(|b| String::from_utf8_lossy(&b).into_owned());
|
||||
let read = |n: &str| read_text(&dir.join(n));
|
||||
let cities = read(GEONAMES_FILES[0]).map_err(|e| GeocodeError::Missing(format!("the place names aren't downloaded ({e})")))?;
|
||||
let g = GeoNames::parse(&cities, read(GEONAMES_FILES[1]).ok().as_deref(), read(GEONAMES_FILES[2]).ok().as_deref());
|
||||
if g.is_empty() {
|
||||
@@ -262,6 +281,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_of_tabs_and_a_non_regular_file_are_handled() {
|
||||
assert!(GeoNames::parse(&"\t".repeat(100_000), None, None).is_empty());
|
||||
assert!(read_text(&std::env::temp_dir()).is_err());
|
||||
#[cfg(unix)]
|
||||
assert!(read_text(Path::new("/dev/zero")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_latitude_cities_two_cells_away() {
|
||||
let city = |lat: f64, lon: f64| GeoNames::parse(&format!("1\tX\tX\t\t{lat}\t{lon}\tP\tP\tNO\t\t\n"), None, None);
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn extract(zip: &[u8], name: &str, max: usize) -> Result<Vec<u8>, GeocodeErr
|
||||
0 if data.len() <= max => Ok(data.to_vec()),
|
||||
0 => Err(GeocodeError::Invalid(format!("{name} is larger than {max} bytes"))),
|
||||
8 => {
|
||||
let decoded = miniz_oxide::inflate::decompress_to_vec_with_limit(data, max).map_err(|e| bad(&format!("deflate: {e:?}")))?;
|
||||
let decoded = miniz_oxide::inflate::decompress_to_vec_with_limit(data, max).map_err(|e| bad(&format!("deflate: {:?}", e.status)))?;
|
||||
if decoded.len() != usize_ {
|
||||
return Err(bad("uncompressed size"));
|
||||
}
|
||||
|
||||
@@ -427,6 +427,8 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("lc-gpu-marker-{}", std::process::id()));
|
||||
let m = dir.join("gpu-init.marker");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// exclusive: a directory planted at this predictable name fails the test instead of receiving the marker
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
// a local scope counter: other tests create real devices through the process-wide one
|
||||
let active = Mutex::new(0);
|
||||
let seen = with_marker_on(Some(m.clone()), &active, "device creation", || m.exists());
|
||||
@@ -447,6 +449,8 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("lc-gpu-global-{}", std::process::id()));
|
||||
let m = dir.join("gpu-init.marker");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// exclusive: a directory planted at this predictable name fails the test instead of receiving the marker
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
set_init_marker(Some(m.clone()));
|
||||
let seen = with_init_marker(Backends::DX12, || m.exists());
|
||||
set_init_marker(None);
|
||||
@@ -501,6 +505,8 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("lc-gpu-scopes-{}", std::process::id()));
|
||||
let m = dir.join("gpu-init.marker");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// exclusive: a directory planted at this predictable name fails the test instead of receiving the marker
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let active = Mutex::new(0);
|
||||
arm(&m, &active, "device creation");
|
||||
assert!(read_init_marker(&m).unwrap().contains("device creation"));
|
||||
@@ -524,6 +530,8 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("lc-gpu-panic-{}", std::process::id()));
|
||||
let m = dir.join("gpu-init.marker");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// exclusive: a directory planted at this predictable name fails the test instead of receiving the marker
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let active = Mutex::new(0);
|
||||
let r = std::panic::catch_unwind(|| with_marker_on(Some(m.clone()), &active, "kernel compilation", || panic!("compile")));
|
||||
assert!(r.is_err());
|
||||
|
||||
@@ -1004,6 +1004,11 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
||||
}
|
||||
let comp_plane = cx.gpu.buffer(n);
|
||||
for (mi, m) in list.iter().enumerate() {
|
||||
// its coordinates on the photo before the warp (`masks::evaluate_one`): through a lens or
|
||||
// perspective warp the kernels' affine positions don't hold, so its shapes run on the CPU
|
||||
let mframe = plan.frame.for_mask(m.on_photo);
|
||||
let frame = &*mframe;
|
||||
let warped = frame.mask_warp().is_some();
|
||||
let mut first = true;
|
||||
for comp in &m.components {
|
||||
let op = match comp.op {
|
||||
@@ -1019,6 +1024,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
||||
let mut aux: Vec<f32> = Vec::new();
|
||||
let mut own: Option<Buf> = None;
|
||||
let kind = match &comp.shape {
|
||||
MaskShape::Linear { .. } | MaskShape::Radial { .. } if warped => None,
|
||||
MaskShape::Linear { start, end } => {
|
||||
let (a, b) = (frame.norm_to_long(*start), frame.norm_to_long(*end));
|
||||
let d = b - a;
|
||||
@@ -1148,6 +1154,7 @@ mod tests {
|
||||
}),
|
||||
vignette: None,
|
||||
stages: Default::default(),
|
||||
source: Default::default(),
|
||||
};
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.lens_profile = true;
|
||||
|
||||
@@ -582,6 +582,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();
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! On the GPU, masks stay on the image content under every geometry change:
|
||||
//! a mask drawn around a mark still brightens that mark after Straighten, a crop, a flip, a quarter
|
||||
//! turn or a lens / perspective warp, rendered fresh or through the view's stage cache. (The CPU
|
||||
//! side is `lightcraft-pipeline`'s `tests_mask_geometry`.)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_develop::{BrushStroke, DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, SegMask};
|
||||
use lightcraft_geom::{CropGeometry, Point, Rect};
|
||||
use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache};
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
|
||||
const W: usize = 240;
|
||||
const H: usize = 160;
|
||||
const MARK: (f64, f64) = (0.3, 0.32);
|
||||
|
||||
fn gpu() -> bool {
|
||||
let ok = lightcraft_gpu::available();
|
||||
if !ok {
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter");
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
fn marked(mark: bool) -> Rgb32f {
|
||||
let (mx, my) = (MARK.0 * W as f64, MARK.1 * H as f64);
|
||||
Rgb32f::from_fn(W, H, |x, y| {
|
||||
let d = ((x as f64 + 0.5 - mx).powi(2) + (y as f64 + 0.5 - my).powi(2)).sqrt();
|
||||
if d < 2.5 && mark { [0.9; 3] } else { [0.05; 3] }
|
||||
})
|
||||
}
|
||||
|
||||
fn with_mask(shape: MaskShape) -> DevelopSettings {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.masks.push(Mask {
|
||||
id: 1,
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
|
||||
adjust: LocalAdjustments { exposure: 2.0, ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
fn shapes() -> Vec<(&'static str, MaskShape)> {
|
||||
let l = W.max(H) as f64;
|
||||
const SIDE: usize = 64;
|
||||
let logits: Vec<f32> = (0..SIDE * SIDE)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % SIDE) as f64 + 0.5, (i / SIDE) as f64 + 0.5);
|
||||
let (dx, dy) = ((x / SIDE as f64 - MARK.0) * W as f64, (y / SIDE as f64 - MARK.1) * H as f64);
|
||||
if (dx * dx + dy * dy).sqrt() < 10.0 { 8.0 } else { -8.0 }
|
||||
})
|
||||
.collect();
|
||||
vec![
|
||||
("radial", MaskShape::Radial { center: Point::new(MARK.0, MARK.1), rx: 12.0 / l, ry: 12.0 / l, angle: 0.0, feather: 40.0, invert: false }),
|
||||
(
|
||||
"brush",
|
||||
MaskShape::Brush {
|
||||
strokes: vec![BrushStroke {
|
||||
points: vec![Point::new(MARK.0 - 0.005, MARK.1), Point::new(MARK.0 + 0.005, MARK.1)],
|
||||
size: 8.0 / l,
|
||||
..Default::default()
|
||||
}],
|
||||
},
|
||||
),
|
||||
(
|
||||
"object",
|
||||
MaskShape::Object {
|
||||
hint: vec![Point::new(MARK.0, MARK.1)],
|
||||
exclude: Vec::new(),
|
||||
seg: Some(SegMask::from_logits(SIDE, &logits)),
|
||||
detail: Vec::new(),
|
||||
edge: 0.0,
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn geometries() -> Vec<(&'static str, Box<dyn Fn(&mut DevelopSettings)>)> {
|
||||
vec![
|
||||
("straighten", Box::new(|s: &mut DevelopSettings| s.crop.geometry = CropGeometry { rect: Rect::new(0.08, 0.1, 0.92, 0.9), angle: 7.0 })),
|
||||
("crop", Box::new(|s: &mut DevelopSettings| s.crop.geometry.rect = Rect::new(0.1, 0.15, 0.7, 0.8))),
|
||||
("flip", Box::new(|s: &mut DevelopSettings| s.crop.flip_h = true)),
|
||||
(
|
||||
"perspective",
|
||||
Box::new(|s: &mut DevelopSettings| {
|
||||
s.geometry.vertical = 25.0;
|
||||
s.geometry.rotate = 4.0;
|
||||
}),
|
||||
),
|
||||
("distortion", Box::new(|s: &mut DevelopSettings| s.optics.distortion = 40.0)),
|
||||
(
|
||||
// Rotate Right: the photo and its masks turn together (`photo.rotateRight`)
|
||||
"quarter turn",
|
||||
Box::new(|s: &mut DevelopSettings| {
|
||||
let turn = lightcraft_geom::Orientation::Normal.rotated(true);
|
||||
s.orientation = s.orientation.rotated(true);
|
||||
for m in &mut s.masks {
|
||||
lightcraft_pipeline::mask_space::turn(m, turn);
|
||||
}
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn luma(img: &Rgba8, i: usize) -> f64 {
|
||||
let p = img.data[i];
|
||||
(p[0] as f64 + p[1] as f64 + p[2] as f64) / 3.0
|
||||
}
|
||||
|
||||
fn centroid(w: usize, weights: impl Iterator<Item = f64>) -> (f64, f64) {
|
||||
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0);
|
||||
for (i, v) in weights.enumerate() {
|
||||
sx += v * ((i % w) as f64 + 0.5);
|
||||
sy += v * ((i / w) as f64 + 0.5);
|
||||
n += v;
|
||||
}
|
||||
assert!(n > 0.0, "nothing found");
|
||||
(sx / n, sy / n)
|
||||
}
|
||||
|
||||
fn on_gpu(src: &Arc<Rgb32f>, s: &DevelopSettings, cache: Option<&StageCache>) -> Rgba8 {
|
||||
let req = RenderRequest::fit(W, H);
|
||||
lightcraft_gpu::try_render(src, &SourceInfo::default(), s, &req, cache).map(|r| r.image).unwrap_or_else(|e| panic!("GPU render failed: {e:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_stay_on_the_content_under_every_geometry_change_on_the_gpu() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let src = Arc::new(marked(true));
|
||||
for (name, shape) in shapes() {
|
||||
for (what, change) in geometries() {
|
||||
let mut s = with_mask(shape.clone());
|
||||
let cache = StageCache::default();
|
||||
on_gpu(&src, &s, Some(&cache));
|
||||
change(&mut s);
|
||||
let unmasked = DevelopSettings { masks: Vec::new(), ..s.clone() };
|
||||
let plain = on_gpu(&src, &unmasked, None);
|
||||
// (where the marked photo differs from an unmarked one: a warp leaves light blank canvas)
|
||||
let bare = on_gpu(&Arc::new(marked(false)), &unmasked, None);
|
||||
let mark = centroid(plain.width, (0..plain.data.len()).map(|i| (luma(&plain, i) - luma(&bare, i)).max(0.0)));
|
||||
for (how, m) in [("fresh", on_gpu(&src, &s, None)), ("cached", on_gpu(&src, &s, Some(&cache)))] {
|
||||
let at = centroid(m.width, (0..m.data.len()).map(|i| (luma(&m, i) - luma(&plain, i)).max(0.0)));
|
||||
let d = ((at.0 - mark.0).powi(2) + (at.1 - mark.1).powi(2)).sqrt();
|
||||
assert!(d < 2.0, "{name} after {what} ({how}): the mask's effect is at {at:?}, the mark at {mark:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
-57
@@ -30,6 +30,11 @@ pub use model::{Distortion, Tca, Vignetting};
|
||||
const MAX_FILE_BYTES: u64 = 16 << 20;
|
||||
/// Most files read from one directory.
|
||||
const MAX_FILES: usize = 2000;
|
||||
/// Most bytes of lens names that profiles may copy per parsed document (each calibration owns a copy): a real
|
||||
/// database copies about a megabyte.
|
||||
const MAX_PROFILE_COPY_BYTES: usize = 32 << 20;
|
||||
/// What one copied name costs besides its bytes: the `String` slot in its `Vec` and the allocator's header and rounding.
|
||||
const NAME_ALLOCATION: usize = std::mem::size_of::<String>() + 32;
|
||||
/// Largest radial error (half-diagonal units) accepted when converting a distortion to the DNG polynomial.
|
||||
pub const MAX_WARP_ERROR: f64 = 0.001;
|
||||
/// Largest relative error accepted when converting vignetting to the DNG polynomial.
|
||||
@@ -60,7 +65,8 @@ pub struct Camera {
|
||||
pub maker: String,
|
||||
pub model: String,
|
||||
pub mount: String,
|
||||
pub crop_factor: f64,
|
||||
/// Sensor crop relative to 35 mm; missing or invalid XML remains unknown.
|
||||
pub crop_factor: Option<f64>,
|
||||
}
|
||||
|
||||
/// One calibrated vignetting point.
|
||||
@@ -80,8 +86,8 @@ pub struct Profile {
|
||||
/// Every name the lens goes by (EXIF variants, translations).
|
||||
pub models: Vec<String>,
|
||||
pub mounts: Vec<String>,
|
||||
/// Crop factor of the camera the calibration was made on.
|
||||
pub crop_factor: f64,
|
||||
/// Crop factor of the calibration frame; missing or invalid XML remains unknown.
|
||||
pub crop_factor: Option<f64>,
|
||||
/// Long / short side of the calibration frame.
|
||||
pub aspect_ratio: f64,
|
||||
/// False for fisheye and other non-rectilinear projections: distortion is then not applied.
|
||||
@@ -144,7 +150,8 @@ impl LensDb {
|
||||
let root = xml::parse(xml)?;
|
||||
let Some(top) = root.kid("lensdatabase") else { return Err(Error::Xml("no <lensdatabase> element".into())) };
|
||||
let cameras = top.kids("camera").filter_map(camera).collect();
|
||||
let lenses = top.kids("lens").flat_map(lens).collect();
|
||||
let mut budget = MAX_PROFILE_COPY_BYTES;
|
||||
let lenses = top.kids("lens").flat_map(|n| lens(n, &mut budget)).collect();
|
||||
Ok(LensDb { cameras, lenses })
|
||||
}
|
||||
|
||||
@@ -205,7 +212,7 @@ impl LensDb {
|
||||
{
|
||||
score *= 0.8;
|
||||
}
|
||||
if score >= 0.6 && best.is_none_or(|(s, _)| score > s) {
|
||||
if score >= 0.6 && best.is_none_or(|(s, b)| score > s || (score == s && b.crop_factor.is_none() && lens.crop_factor.is_some())) {
|
||||
best = Some((score, lens));
|
||||
}
|
||||
}
|
||||
@@ -244,7 +251,7 @@ impl LensDb {
|
||||
continue;
|
||||
}
|
||||
let score = focals.iter().filter(|f| (lo..=hi).contains(*f)).count() as f64 / focals.len() as f64;
|
||||
if score > 0.0 && best.is_none_or(|(s, _)| score > s) {
|
||||
if score > 0.0 && best.is_none_or(|(s, b)| score > s || (score == s && b.crop_factor.is_none() && p.crop_factor.is_some())) {
|
||||
best = Some((score, p));
|
||||
}
|
||||
}
|
||||
@@ -272,11 +279,26 @@ fn exif_focal_range(exif: &[String]) -> Option<(f64, f64)> {
|
||||
}
|
||||
|
||||
fn read_capped(path: &Path) -> Result<String, Error> {
|
||||
let len = std::fs::metadata(path).map_err(|e| Error::Io(e.to_string()))?.len();
|
||||
if len > MAX_FILE_BYTES {
|
||||
use std::io::Read;
|
||||
let io = |e: std::io::Error| Error::Io(e.to_string());
|
||||
// the pathname is checked before opening (opening a FIFO for reading blocks until a writer appears), then the
|
||||
// opened file again: a link or FIFO swapped in after the pathname check is refused too
|
||||
if !std::fs::metadata(path).map_err(io)?.is_file() {
|
||||
return Err(Error::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
let file = std::fs::File::open(path).map_err(io)?;
|
||||
let meta = file.metadata().map_err(io)?;
|
||||
if !meta.is_file() {
|
||||
return Err(Error::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
if meta.len() > MAX_FILE_BYTES {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes).map_err(io)?;
|
||||
if bytes.len() as u64 > MAX_FILE_BYTES {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| Error::Io(e.to_string()))?;
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
@@ -327,7 +349,9 @@ fn maker_matches(a: &[String], b: &[String]) -> bool {
|
||||
}
|
||||
|
||||
/// How well a database name matches the EXIF name, 0..1. Every number in the database name (focal lengths,
|
||||
/// apertures) must appear in the EXIF name; the maker's words are ignored on both sides (EXIF often omits them).
|
||||
/// apertures) must appear in the EXIF name and vice versa; the maker's words are ignored on both sides (EXIF
|
||||
/// often omits them). Extra EXIF words only lower the score; they name a different lens only when they mark
|
||||
/// a teleconverter, a different optical system than the bare lens.
|
||||
fn name_score(db: &[String], exif: &[String], maker: &[String]) -> f64 {
|
||||
let db: Vec<&String> = db.iter().filter(|t| !maker.contains(t)).collect();
|
||||
let exif: Vec<&String> = exif.iter().filter(|t| !maker.contains(t)).collect();
|
||||
@@ -337,16 +361,33 @@ fn name_score(db: &[String], exif: &[String], maker: &[String]) -> f64 {
|
||||
if db.iter().any(|t| is_number(t) && !exif.contains(t)) || exif.iter().any(|t| is_number(t) && !db.contains(t)) {
|
||||
return 0.0;
|
||||
}
|
||||
// A teleconverter combo is a different optical system from the bare lens:
|
||||
// reject it whichever side carries the marker, independent of which tokens
|
||||
// the names drop or add.
|
||||
let db_tc = db.iter().any(|t| is_teleconverter_marker(t.as_str()));
|
||||
let exif_tc = exif.iter().any(|t| is_teleconverter_marker(t.as_str()));
|
||||
if db_tc != exif_tc {
|
||||
return 0.0;
|
||||
}
|
||||
let common = db.iter().filter(|t| exif.contains(t)).count() as f64;
|
||||
common / db.len().max(exif.len()) as f64
|
||||
}
|
||||
|
||||
/// Teleconverter markers in a lens name. A name carrying one names a different
|
||||
/// optical system than the bare lens, so a match whose sides disagree on the
|
||||
/// marker is rejected before scoring. Converter factors such as `1.4x`
|
||||
/// tokenise into a number the base profile lacks, so the numeric check above
|
||||
/// already rejects them.
|
||||
fn is_teleconverter_marker(t: &str) -> bool {
|
||||
t == "tc" || t == "teleconverter"
|
||||
}
|
||||
|
||||
fn camera(n: &xml::Node) -> Option<Camera> {
|
||||
Some(Camera {
|
||||
maker: n.kid_text("maker")?.to_string(),
|
||||
model: n.kid_text("model")?.to_string(),
|
||||
mount: n.kid_text("mount").unwrap_or_default().to_string(),
|
||||
crop_factor: crop_of(n.kid_text("cropfactor")).unwrap_or(1.0),
|
||||
crop_factor: crop_of(n.kid_text("cropfactor")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -369,60 +410,80 @@ fn positive(v: Option<f64>) -> Option<f64> {
|
||||
}
|
||||
|
||||
/// One `<lens>` element → one profile per `<calibration>` (newer files give each its own crop factor and aspect).
|
||||
fn lens(n: &xml::Node) -> Vec<Profile> {
|
||||
fn lens(n: &xml::Node, copy_budget: &mut usize) -> Vec<Profile> {
|
||||
let Some(maker) = n.kid_text("maker") else { return Vec::new() };
|
||||
let models: Vec<String> = n.kids("model").map(|m| m.text.trim().to_string()).filter(|m| !m.is_empty()).collect();
|
||||
if models.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let crop = crop_of(n.kid_text("cropfactor")).unwrap_or(1.0);
|
||||
let crop = crop_of(n.kid_text("cropfactor"));
|
||||
let aspect = aspect_of(n.kid_text("aspect-ratio")).unwrap_or(1.5);
|
||||
let rectilinear = n.kid_text("type").is_none_or(|t| t.eq_ignore_ascii_case("rectilinear"));
|
||||
let mounts = n.kids("mount").map(|m| m.text.trim().to_string()).filter(|m| !m.is_empty()).collect::<Vec<_>>();
|
||||
n.kids("calibration")
|
||||
.map(|cal| {
|
||||
let mut p = Profile {
|
||||
maker: maker.to_string(),
|
||||
models: models.clone(),
|
||||
mounts: mounts.clone(),
|
||||
crop_factor: crop_of(cal.attr("cropfactor")).unwrap_or(crop),
|
||||
aspect_ratio: aspect_of(cal.attr("aspect-ratio")).unwrap_or(aspect),
|
||||
rectilinear,
|
||||
distortion: Vec::new(),
|
||||
tca: Vec::new(),
|
||||
vignetting: Vec::new(),
|
||||
};
|
||||
for c in &cal.children {
|
||||
let Some(focal) = positive(c.num("focal")) else { continue };
|
||||
let k = |name: &str| c.num(name).unwrap_or(0.0);
|
||||
match (c.name.as_str(), c.attr("model")) {
|
||||
("distortion", Some("poly3")) => p.distortion.push((focal, Distortion::Poly3 { k1: k("k1") })),
|
||||
("distortion", Some("poly5")) => p.distortion.push((focal, Distortion::Poly5 { k1: k("k1"), k2: k("k2") })),
|
||||
("distortion", Some("ptlens")) => p.distortion.push((focal, Distortion::PtLens { a: k("a"), b: k("b"), c: k("c") })),
|
||||
("tca", Some("linear")) => {
|
||||
let one = |name: &str| c.num(name).unwrap_or(1.0);
|
||||
p.tca.push((focal, Tca::Linear { kr: one("kr"), kb: one("kb") }));
|
||||
}
|
||||
("tca", Some("poly3")) => {
|
||||
let one = |name: &str| c.num(name).unwrap_or(1.0);
|
||||
p.tca.push((focal, Tca::Poly3 { vr: one("vr"), vb: one("vb"), cr: k("cr"), cb: k("cb"), br: k("br"), bb: k("bb") }));
|
||||
}
|
||||
("vignetting", Some("pa")) => {
|
||||
let (Some(aperture), distance) = (positive(c.num("aperture")), positive(c.num("distance")).unwrap_or(1000.0)) else {
|
||||
continue;
|
||||
};
|
||||
let model = Vignetting { k1: k("k1"), k2: k("k2"), k3: k("k3") };
|
||||
p.vignetting.push(VignettingPoint { focal, aperture, distance, model });
|
||||
}
|
||||
_ => {}
|
||||
// every kept calibration owns a copy of the lens's names, charged to the document's shared copy budget (the
|
||||
// strings' own allocations count, not only their bytes): when it can't cover another copy, the rest are left out
|
||||
let meta_bytes = std::mem::size_of::<Profile>()
|
||||
+ NAME_ALLOCATION
|
||||
+ maker.len()
|
||||
+ models.iter().chain(&mounts).map(|m| m.len() + NAME_ALLOCATION).sum::<usize>();
|
||||
let mut out = Vec::new();
|
||||
for cal in n.kids("calibration") {
|
||||
if *copy_budget < meta_bytes {
|
||||
break;
|
||||
}
|
||||
let mut p = Profile {
|
||||
maker: String::new(),
|
||||
models: Vec::new(),
|
||||
mounts: Vec::new(),
|
||||
// Only an omitted override inherits; an explicit invalid value remains unknown.
|
||||
crop_factor: match cal.attr("cropfactor") {
|
||||
Some(value) => crop_of(Some(value)),
|
||||
None => crop,
|
||||
},
|
||||
aspect_ratio: aspect_of(cal.attr("aspect-ratio")).unwrap_or(aspect),
|
||||
rectilinear,
|
||||
distortion: Vec::new(),
|
||||
tca: Vec::new(),
|
||||
vignetting: Vec::new(),
|
||||
};
|
||||
for c in &cal.children {
|
||||
let Some(focal) = positive(c.num("focal")) else { continue };
|
||||
let k = |name: &str| c.num(name).unwrap_or(0.0);
|
||||
match (c.name.as_str(), c.attr("model")) {
|
||||
("distortion", Some("poly3")) => p.distortion.push((focal, Distortion::Poly3 { k1: k("k1") })),
|
||||
("distortion", Some("poly5")) => p.distortion.push((focal, Distortion::Poly5 { k1: k("k1"), k2: k("k2") })),
|
||||
("distortion", Some("ptlens")) => p.distortion.push((focal, Distortion::PtLens { a: k("a"), b: k("b"), c: k("c") })),
|
||||
("tca", Some("linear")) => {
|
||||
let one = |name: &str| c.num(name).unwrap_or(1.0);
|
||||
p.tca.push((focal, Tca::Linear { kr: one("kr"), kb: one("kb") }));
|
||||
}
|
||||
("tca", Some("poly3")) => {
|
||||
let one = |name: &str| c.num(name).unwrap_or(1.0);
|
||||
p.tca.push((focal, Tca::Poly3 { vr: one("vr"), vb: one("vb"), cr: k("cr"), cb: k("cb"), br: k("br"), bb: k("bb") }));
|
||||
}
|
||||
("vignetting", Some("pa")) => {
|
||||
let (Some(aperture), distance) = (positive(c.num("aperture")), positive(c.num("distance")).unwrap_or(1000.0)) else {
|
||||
continue;
|
||||
};
|
||||
let model = Vignetting { k1: k("k1"), k2: k("k2"), k3: k("k3") };
|
||||
p.vignetting.push(VignettingPoint { focal, aperture, distance, model });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
p.distortion.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
p.tca.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
p
|
||||
})
|
||||
.filter(|p| !p.distortion.is_empty() || !p.tca.is_empty() || !p.vignetting.is_empty())
|
||||
.collect()
|
||||
}
|
||||
// a calibration with nothing to apply is dropped before its names are copied or charged
|
||||
if p.distortion.is_empty() && p.tca.is_empty() && p.vignetting.is_empty() {
|
||||
continue;
|
||||
}
|
||||
*copy_budget -= meta_bytes;
|
||||
p.maker = maker.to_string();
|
||||
p.models = models.clone();
|
||||
p.mounts = mounts.clone();
|
||||
p.distortion.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
p.tca.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
out.push(p);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Linear interpolation weights in a sorted key list: the bracketing indices and the weight of the upper one.
|
||||
@@ -514,14 +575,16 @@ impl Profile {
|
||||
/// The correction for `shot`, as the DNG opcode polynomials the pipeline applies, centred on the image.
|
||||
/// Distortion and TCA are combined per plane (R, G, B); a model the polynomial can't follow closely
|
||||
/// ([`MAX_WARP_ERROR`], [`MAX_VIGNETTE_ERROR`]) or that would fold the image is left out.
|
||||
/// An unknown or invalid calibration crop factor cannot be converted.
|
||||
pub fn correction(&self, shot: &Shot) -> Option<EmbeddedLens> {
|
||||
let (w, h) = (shot.width, shot.height);
|
||||
if !(w >= 2.0 && h >= 2.0 && w.is_finite() && h.is_finite() && shot.focal.is_finite() && shot.focal > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let cam = shot.crop_factor.filter(|c| c.is_finite() && *c > 0.0).unwrap_or(self.crop_factor);
|
||||
let calibration_crop = self.crop_factor.filter(|c| c.is_finite() && (0.05..=20.0).contains(c))?;
|
||||
let cam = shot.crop_factor.filter(|c| c.is_finite() && *c > 0.0).unwrap_or(calibration_crop);
|
||||
// DNG radius (1 = image half diagonal) → lensfun radii
|
||||
let to_vig = self.crop_factor / cam;
|
||||
let to_vig = calibration_crop / cam;
|
||||
let to_dist = to_vig * (1.0 + self.aspect_ratio * self.aspect_ratio).sqrt();
|
||||
let center = Point::new(0.5, 0.5);
|
||||
let radius = w.hypot(h) / 2.0 / w.max(h);
|
||||
|
||||
+270
-5
@@ -36,6 +36,7 @@ const DB: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<maker>Testcam</maker>
|
||||
<model>Testcam 50mm f/1.8</model>
|
||||
<mount>Other Mount</mount>
|
||||
<cropfactor>1</cropfactor>
|
||||
<calibration>
|
||||
<distortion model="poly5" focal="50" k1="-0.02" k2="0.0003" />
|
||||
</calibration>
|
||||
@@ -44,6 +45,7 @@ const DB: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<maker>Fishco</maker>
|
||||
<model>Fishco 8mm f/3.5 Fisheye</model>
|
||||
<type>equisolid</type>
|
||||
<cropfactor>1</cropfactor>
|
||||
<calibration>
|
||||
<distortion model="poly3" focal="8" k1="0.5" />
|
||||
</calibration>
|
||||
@@ -101,8 +103,8 @@ fn malformed_documents_are_errors_not_panics() {
|
||||
#[test]
|
||||
fn finds_cameras_by_exif_strings() {
|
||||
let db = db();
|
||||
assert_eq!(db.find_camera("TESTCAM CORPORATION", "TC-1").unwrap().crop_factor, 1.5);
|
||||
assert_eq!(db.find_camera("Testcam", "tc-ff").unwrap().crop_factor, 1.0);
|
||||
assert_eq!(db.find_camera("TESTCAM CORPORATION", "TC-1").unwrap().crop_factor, Some(1.5));
|
||||
assert_eq!(db.find_camera("Testcam", "tc-ff").unwrap().crop_factor, Some(1.0));
|
||||
assert!(db.find_camera("Testcam", "TC-2").is_none());
|
||||
assert!(db.find_camera("Other", "TC-1").is_none());
|
||||
}
|
||||
@@ -232,7 +234,8 @@ fn fisheyes_get_no_rectilinear_warp_and_bad_shots_none() {
|
||||
#[test]
|
||||
fn load_dir_reads_xml_files_and_reports_bad_ones() {
|
||||
let dir = std::env::temp_dir().join(format!("lensdb-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// exclusive: an existing (possibly planted) entry fails the test instead of being written through
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.xml"), DB).unwrap();
|
||||
std::fs::write(dir.join("b.xml"), "<lensdatabase").unwrap();
|
||||
std::fs::write(dir.join("c.txt"), "ignored").unwrap();
|
||||
@@ -293,6 +296,140 @@ fn a_named_profile_is_found_behind_a_tie() {
|
||||
assert!(db.find_lens_named(None, "70-200mm f/4", None).is_none());
|
||||
}
|
||||
|
||||
fn crop_xml(parent: &str, calibration: &str) -> String {
|
||||
format!(
|
||||
r#"<lensdatabase><lens><maker>Test</maker><model>Test 50mm</model>{parent}
|
||||
<calibration {calibration}><distortion model="poly3" focal="50" k1="-0.01" /></calibration>
|
||||
</lens></lensdatabase>"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tied_lens_matches_prefer_a_known_calibration_crop() {
|
||||
let name = spec_name([50.0, 50.0, 1.8, 1.8]);
|
||||
for (parent, unknown, known) in [
|
||||
("", "", r#"cropfactor="1""#),
|
||||
("<cropfactor>bad</cropfactor>", "", r#"cropfactor="1""#),
|
||||
("<cropfactor>1</cropfactor>", r#"cropfactor="bad""#, ""),
|
||||
("<cropfactor>1</cropfactor>", r#"cropfactor="bad""#, r#"cropfactor="1""#),
|
||||
] {
|
||||
let calibration = |attributes: &str| format!(r#"<calibration {attributes}><distortion model="poly3" focal="50" k1="-0.01" /></calibration>"#);
|
||||
for (first, second) in [(unknown, known), (known, unknown)] {
|
||||
let xml = format!(
|
||||
"<lensdatabase><lens><maker>Test</maker><model>{name}</model>{parent}{}{}</lens></lensdatabase>",
|
||||
calibration(first),
|
||||
calibration(second),
|
||||
);
|
||||
let db = LensDb::parse(&xml).unwrap();
|
||||
assert_eq!(db.lenses.len(), 2);
|
||||
assert_eq!(db.lenses.iter().filter(|p| p.crop_factor.is_none()).count(), 1);
|
||||
for profile in [db.find_lens(None, &name, None), db.find_lens_named(None, &name, None)] {
|
||||
let profile = profile.unwrap();
|
||||
assert_eq!(profile.crop_factor, Some(1.0), "{parent}: {first}, {second}");
|
||||
assert!(profile.correction(&shot(50.0)).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_higher_lens_score_still_wins_with_unknown_crop() {
|
||||
let mut db = db();
|
||||
let camera = db.cameras[0].clone();
|
||||
let mut unknown = db.lenses[0].clone();
|
||||
unknown.crop_factor = None;
|
||||
let mut known = unknown.clone();
|
||||
known.crop_factor = Some(1.0);
|
||||
known.mounts = vec!["Other Mount".into()];
|
||||
let name = unknown.models[0].clone();
|
||||
for lenses in [vec![unknown.clone(), known.clone()], vec![known, unknown]] {
|
||||
db.lenses = lenses;
|
||||
for profile in [db.find_lens(None, &name, Some(&camera)), db.find_lens_named(None, &name, Some(&camera))] {
|
||||
assert_eq!(profile.unwrap().crop_factor, None, "the better mount match outranks crop availability");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_calibration_crop_is_not_assumed_full_frame() {
|
||||
for parent in [
|
||||
"",
|
||||
"<cropfactor/>",
|
||||
"<cropfactor>bad</cropfactor>",
|
||||
"<cropfactor>NaN</cropfactor>",
|
||||
"<cropfactor>inf</cropfactor>",
|
||||
"<cropfactor>0</cropfactor>",
|
||||
"<cropfactor>-1</cropfactor>",
|
||||
"<cropfactor>0.049</cropfactor>",
|
||||
"<cropfactor>21</cropfactor>",
|
||||
] {
|
||||
let db = LensDb::parse(&crop_xml(parent, "")).unwrap();
|
||||
assert_eq!(db.lenses[0].crop_factor, None, "{parent}");
|
||||
assert!(db.lenses[0].correction(&shot(50.0)).is_none(), "unknown calibration crop: {parent}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_calibration_override_does_not_inherit_parent_crop() {
|
||||
for value in ["", "bad", "NaN", "inf", "0", "-1", "0.049", "21"] {
|
||||
let db = LensDb::parse(&crop_xml("<cropfactor>1.5</cropfactor>", &format!(r#"cropfactor="{value}""#))).unwrap();
|
||||
assert_eq!(db.lenses[0].crop_factor, None, "{value}");
|
||||
assert!(db.lenses[0].correction(&shot(50.0)).is_none(), "invalid calibration override: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_crop_inherits_only_when_omitted_and_accepts_valid_overrides() {
|
||||
let inherited = LensDb::parse(&crop_xml("<cropfactor>1.5</cropfactor>", "")).unwrap();
|
||||
assert_eq!(inherited.lenses[0].crop_factor, Some(1.5));
|
||||
assert!(inherited.lenses[0].correction(&shot(50.0)).is_some());
|
||||
for parent in ["", "<cropfactor>bad</cropfactor>", "<cropfactor>1.5</cropfactor>"] {
|
||||
for value in ["0.05", "1", "2", "20"] {
|
||||
let db = LensDb::parse(&crop_xml(parent, &format!(r#"cropfactor="{value}""#))).unwrap();
|
||||
assert_eq!(db.lenses[0].crop_factor, value.parse().ok());
|
||||
// With no shot crop the valid calibration supplies the scale, even at the range boundaries.
|
||||
assert!(db.lenses[0].correction(&Shot { crop_factor: None, ..shot(50.0) }).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_crop_preserves_unknown_and_explicit_full_frame() {
|
||||
for (crop, expected) in [
|
||||
("", None),
|
||||
("bad", None),
|
||||
("NaN", None),
|
||||
("inf", None),
|
||||
("0", None),
|
||||
("-1", None),
|
||||
("0.049", None),
|
||||
("21", None),
|
||||
("0.05", Some(0.05)),
|
||||
("1", Some(1.0)),
|
||||
("20", Some(20.0)),
|
||||
] {
|
||||
let xml = format!(r#"<lensdatabase><camera><maker>Test</maker><model>Body</model><cropfactor>{crop}</cropfactor></camera></lensdatabase>"#);
|
||||
let db = LensDb::parse(&xml).unwrap();
|
||||
assert_eq!(db.cameras[0].crop_factor, expected, "{crop}");
|
||||
}
|
||||
let db = LensDb::parse("<lensdatabase><camera><maker>Test</maker><model>Body</model></camera></lensdatabase>").unwrap();
|
||||
assert_eq!(db.cameras[0].crop_factor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_profile_api_rejects_invalid_calibration_and_preserves_shot_fallback() {
|
||||
let mut profile = db().lenses[0].clone();
|
||||
for crop in [None, Some(f64::NAN), Some(f64::INFINITY), Some(0.0), Some(-1.0), Some(0.049), Some(21.0)] {
|
||||
profile.crop_factor = crop;
|
||||
assert!(profile.correction(&shot(24.0)).is_none(), "{crop:?}");
|
||||
}
|
||||
profile.crop_factor = Some(1.0);
|
||||
let expected = profile.correction(&shot(24.0)).unwrap();
|
||||
for crop in [None, Some(f64::NAN), Some(f64::INFINITY), Some(0.0), Some(-1.0)] {
|
||||
assert_eq!(profile.correction(&Shot { crop_factor: crop, ..shot(24.0) }).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_lens_matches_by_camera_mount_and_focal_range() {
|
||||
let xml = r#"<lensdatabase>
|
||||
@@ -319,6 +456,42 @@ fn built_in_lens_matches_by_camera_mount_and_focal_range() {
|
||||
assert_eq!(exif_focal_range(&tokens(&huge)), Some((6.0, 6.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tied_built_in_lens_matches_prefer_a_known_calibration_crop() {
|
||||
let mut db = db();
|
||||
let cam = db.cameras[0].clone();
|
||||
let mut unknown = db.lenses[0].clone();
|
||||
unknown.models = vec!["Testcam Compact".into()];
|
||||
unknown.crop_factor = None;
|
||||
let mut known = unknown.clone();
|
||||
known.crop_factor = Some(1.5);
|
||||
for lenses in [vec![unknown.clone(), known.clone()], vec![known, unknown]] {
|
||||
db.lenses = lenses;
|
||||
let lens = db.find_lens(None, "24-70 mm f/2.8", Some(&cam)).unwrap();
|
||||
assert_eq!(lens.crop_factor, Some(1.5), "an exact focal-range score tie prefers the known crop in either order");
|
||||
assert!(lens.correction(&shot(24.0)).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_higher_built_in_lens_score_still_wins_with_unknown_crop() {
|
||||
let mut db = db();
|
||||
let cam = db.cameras[0].clone();
|
||||
let mut unknown = db.lenses[0].clone();
|
||||
unknown.models = vec!["Testcam Compact".into()];
|
||||
unknown.crop_factor = None;
|
||||
unknown.tca.clear();
|
||||
unknown.vignetting.clear();
|
||||
let mut known = unknown.clone();
|
||||
known.crop_factor = Some(1.5);
|
||||
known.distortion[1].0 = 100.0;
|
||||
for lenses in [vec![unknown.clone(), known.clone()], vec![known, unknown]] {
|
||||
db.lenses = lenses;
|
||||
let lens = db.find_lens(None, "24-70 mm f/2.8", Some(&cam)).unwrap();
|
||||
assert_eq!(lens.crop_factor, None, "the higher focal-range score remains the primary criterion in either order");
|
||||
}
|
||||
}
|
||||
|
||||
/// A hand-edited or corrupt database may carry any number: infinities, NaN, enormous or negative
|
||||
/// coefficients, absurd crop factors and aspect ratios. Whatever loads must give corrections that
|
||||
/// are `None` or entirely finite, for any shot including nonsense ones; names of any shape find
|
||||
@@ -333,10 +506,10 @@ fn hostile_numbers_give_finite_corrections_or_none() {
|
||||
r#"<lens><maker>M</maker><model>M 1</model><cropfactor>{v}</cropfactor><aspect-ratio>{v}</aspect-ratio><calibration><distortion model="poly3" focal="24" k1="0.01"/><vignetting model="pa" focal="24" aperture="2.8" k1="-0.3"/></calibration></lens>"#
|
||||
),
|
||||
format!(
|
||||
r#"<lens><maker>M</maker><model>M 1</model><calibration cropfactor="{v}" aspect-ratio="{v}"><distortion model="ptlens" focal="24" a="{v}" b="{v}" c="{v}"/><tca model="poly3" focal="24" vr="{v}" vb="{v}" cr="{v}" cb="{v}" br="{v}" bb="{v}"/></calibration></lens>"#
|
||||
r#"<lens><maker>M</maker><model>M 1</model><calibration cropfactor="1" aspect-ratio="{v}"><distortion model="ptlens" focal="24" a="{v}" b="{v}" c="{v}"/><tca model="poly3" focal="24" vr="{v}" vb="{v}" cr="{v}" cb="{v}" br="{v}" bb="{v}"/></calibration></lens>"#
|
||||
),
|
||||
format!(
|
||||
r#"<lens><maker>M</maker><model>M 1</model><calibration><distortion model="poly5" focal="{v}" k1="{v}" k2="{v}"/><distortion model="poly3" focal="50" k1="{v}"/><vignetting model="pa" focal="{v}" aperture="{v}" distance="{v}" k1="{v}" k2="{v}" k3="{v}"/><tca model="linear" focal="{v}" kr="{v}" kb="{v}"/></calibration></lens>"#
|
||||
r#"<lens><maker>M</maker><model>M 1</model><cropfactor>1</cropfactor><calibration><distortion model="poly5" focal="{v}" k1="{v}" k2="{v}"/><distortion model="poly3" focal="50" k1="{v}"/><vignetting model="pa" focal="{v}" aperture="{v}" distance="{v}" k1="{v}" k2="{v}" k3="{v}"/><tca model="linear" focal="{v}" kr="{v}" kb="{v}"/></calibration></lens>"#
|
||||
),
|
||||
] {
|
||||
let Ok(db) = LensDb::parse(&format!("<lensdatabase>{xml}</lensdatabase>")) else { continue };
|
||||
@@ -371,3 +544,95 @@ fn hostile_numbers_give_finite_corrections_or_none() {
|
||||
assert!(db.find_camera("", "").is_none());
|
||||
assert!(db.find_lens(None, " ", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrations_past_the_shared_copy_budget_are_left_out() {
|
||||
let name = "x".repeat(400_000);
|
||||
let cal = "<calibration focal=\"50\"><distortion model=\"poly3\" focal=\"50\" k1=\"0.01\"/></calibration>";
|
||||
let xml = format!("<lensdatabase><lens><maker>M</maker><model>{name}</model>{}</lens></lensdatabase>", cal.repeat(2000));
|
||||
let db = LensDb::parse(&xml).unwrap();
|
||||
assert!(!db.lenses.is_empty());
|
||||
assert!(db.lenses.len() * 400_000 <= MAX_PROFILE_COPY_BYTES);
|
||||
}
|
||||
|
||||
/// Unusable calibrations (nothing to apply) are not copied or charged, so they can't use up the
|
||||
/// budget a later usable one of the same lens needs.
|
||||
#[test]
|
||||
fn unusable_calibrations_do_not_spend_the_copy_budget() {
|
||||
let name = "x".repeat(8 << 20);
|
||||
let empty = "<calibration focal=\"50\"></calibration>";
|
||||
let good = "<calibration focal=\"50\"><distortion model=\"poly3\" focal=\"50\" k1=\"0.01\"/></calibration>";
|
||||
let xml = format!("<lensdatabase><lens><maker>M</maker><model>{name}</model>{}{good}</lens></lensdatabase>", empty.repeat(4));
|
||||
let db = LensDb::parse(&xml).unwrap();
|
||||
assert_eq!(db.lenses.len(), 1, "the usable calibration after the empty ones is kept");
|
||||
}
|
||||
|
||||
/// Many tiny names cost their allocations, not only their bytes.
|
||||
#[test]
|
||||
fn many_tiny_names_are_charged_their_allocations() {
|
||||
let models = "<model>a</model>".repeat(400_000);
|
||||
let good = "<calibration focal=\"50\"><distortion model=\"poly3\" focal=\"50\" k1=\"0.01\"/></calibration>";
|
||||
let xml = format!("<lensdatabase><lens><maker>M</maker>{models}{good}{good}{good}{good}</lens></lensdatabase>");
|
||||
let db = LensDb::parse(&xml).unwrap();
|
||||
let names: usize = db.lenses.iter().map(|p| p.models.len()).sum();
|
||||
assert!(db.lenses.len() < 4 && names * std::mem::size_of::<String>() <= MAX_PROFILE_COPY_BYTES, "{} profiles", db.lenses.len());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn load_dir_refuses_a_non_regular_xml_entry() {
|
||||
let dir = std::env::temp_dir().join(format!("lensdb-test-dev-{}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::os::unix::fs::symlink("/dev/zero", dir.join("z.xml")).unwrap();
|
||||
let (db, errs) = LensDb::load_dir(&dir).unwrap();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(db.is_empty());
|
||||
assert_eq!(errs.len(), 1, "{errs:?}");
|
||||
}
|
||||
|
||||
/// A FIFO named `*.xml` is refused without opening it: opening a FIFO for reading blocks until a writer appears.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn load_dir_refuses_a_fifo_without_blocking() {
|
||||
let dir = std::env::temp_dir().join(format!("lensdb-test-fifo-{}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let fifo = dir.join("f.xml");
|
||||
let made = std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success());
|
||||
if !made {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
return;
|
||||
}
|
||||
let (db, errs) = LensDb::load_dir(&dir).unwrap();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(db.is_empty());
|
||||
assert_eq!(errs.len(), 1, "{errs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleconverter_combo_does_not_inherit_base_profile() {
|
||||
// The database has only the bare 400 mm profile. A teleconverter combo is a
|
||||
// different optical system, so find_lens must not return the base profile --
|
||||
// not even when the converter is a digit-free suffix such as "Z TC".
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lensdatabase version="2">
|
||||
<lens>
|
||||
<maker>Nikon</maker>
|
||||
<model>NIKKOR Z 400mm f/4.5 VR S</model>
|
||||
<mount>Z</mount>
|
||||
<calibration>
|
||||
<distortion model="poly3" focal="400" k1="0.001" />
|
||||
</calibration>
|
||||
</lens>
|
||||
</lensdatabase>"#;
|
||||
let db = LensDb::parse(xml).unwrap();
|
||||
assert!(db.find_lens(Some("Nikon"), "NIKKOR Z 400mm f/4.5 VR S", None).is_some(), "the base profile matches its own name");
|
||||
// A digit-free teleconverter suffix must not inherit the 400 mm profile:
|
||||
// name_score finds every base-profile number token present and would score 0.78.
|
||||
assert!(db.find_lens(Some("Nikon"), "NIKKOR Z 400mm f/4.5 VR S Z TC", None).is_none(), "teleconverter combos do not inherit the base profile");
|
||||
// A converter factor ("1.4") is a numeric token the base profile lacks, so the
|
||||
// combo matches nothing either.
|
||||
assert!(
|
||||
db.find_lens(Some("Nikon"), "NIKKOR Z 400mm f/4.5 VR S Z TC-1.4x", None).is_none(),
|
||||
"teleconverter combos do not inherit the base profile",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -33,6 +33,10 @@ pub struct Frame {
|
||||
pub warp: Option<Warp>,
|
||||
/// Set when this frame draws only a window of a larger output (see [`Frame::window`]).
|
||||
pub view: Option<ViewWindow>,
|
||||
/// Mask coordinates are on the photo before the warp ([`Frame::for_mask`],
|
||||
/// `lightcraft_develop::Mask::on_photo`); otherwise they are normalized coordinates of this
|
||||
/// frame's transformed image, like spots and the crop.
|
||||
pub masks_on_photo: bool,
|
||||
}
|
||||
|
||||
/// Where a windowed [`Frame`]'s pixels sit in the full output: its offset and the full output's
|
||||
@@ -77,16 +81,34 @@ impl Frame {
|
||||
flip_v: s.crop.flip_v,
|
||||
warp: (!warp.is_identity()).then_some(warp),
|
||||
view: None,
|
||||
masks_on_photo: false,
|
||||
};
|
||||
if apply_crop && s.geometry.constrain_crop {
|
||||
f.constrain_to_image();
|
||||
if apply_crop {
|
||||
f.constrain_to_image(s.geometry.constrain_crop);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// "Constrain Crop": shrink the crop about its centre until it contains no area outside the warped source.
|
||||
fn constrain_to_image(&mut self) {
|
||||
let Some(wp) = self.warp.as_ref().filter(|w| w.moves_pixels()) else { return };
|
||||
/// Shrink the crop about its centre until it contains no area outside the warped source. Lens
|
||||
/// corrections (profile, embedded camera warp, manual distortion, lateral CA) always do this, as
|
||||
/// a corrected lens never shows the blank past the photo's edge (issue #2361); perspective only
|
||||
/// with "Constrain Crop" (`with_persp`), so a keystone fix can keep its corners.
|
||||
pub(crate) fn constrain_to_image(&mut self, with_persp: bool) {
|
||||
let Some(full) = self.warp.as_ref().filter(|w| w.moves_pixels()) else { return };
|
||||
let optics;
|
||||
let wp = if with_persp || full.persp == Homography::IDENTITY {
|
||||
full
|
||||
} else {
|
||||
let mut o = full.clone();
|
||||
o.persp = Homography::IDENTITY;
|
||||
o.persp_inv = Homography::IDENTITY;
|
||||
optics = o;
|
||||
&optics
|
||||
};
|
||||
if !wp.moves_pixels() {
|
||||
return;
|
||||
}
|
||||
let planes: &[usize] = if wp.per_channel() { &[0, 1, 2] } else { &[1] };
|
||||
let a = self.crop.output_to_source(self.ow, self.oh, 1.0, 1.0);
|
||||
let (w, h) = (self.ow, self.oh);
|
||||
let fits = |t: f64| {
|
||||
@@ -94,8 +116,10 @@ impl Frame {
|
||||
let u = i as f64 / 48.0;
|
||||
[(u, 0.0), (u, 1.0), (0.0, u), (1.0, u)].iter().all(|&(x, y)| {
|
||||
let p = Point::new(0.5 + t * (x - 0.5), 0.5 + t * (y - 0.5));
|
||||
let s = wp.to_source(a.apply(p), 1);
|
||||
s.x >= -1e-6 * w && s.y >= -1e-6 * h && s.x <= w * (1.0 + 1e-6) && s.y <= h * (1.0 + 1e-6)
|
||||
planes.iter().all(|&ch| {
|
||||
let s = wp.to_source(a.apply(p), ch);
|
||||
s.x >= -1e-6 * w && s.y >= -1e-6 * h && s.x <= w * (1.0 + 1e-6) && s.y <= h * (1.0 + 1e-6)
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
@@ -223,6 +247,61 @@ impl Frame {
|
||||
self.out_to_norm(out_w, out_h).inverse().unwrap_or(Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// This frame for evaluating mask `m`: whether its coordinates are on the photo before the
|
||||
/// warp ([`lightcraft_develop::Mask::on_photo`]).
|
||||
pub fn for_mask(&self, on_photo: bool) -> std::borrow::Cow<'_, Frame> {
|
||||
if self.masks_on_photo == on_photo {
|
||||
std::borrow::Cow::Borrowed(self)
|
||||
} else {
|
||||
std::borrow::Cow::Owned(Frame { masks_on_photo: on_photo, ..self.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp mask coordinates go through: the lens / perspective warp when masks are on the
|
||||
/// photo and the warp moves pixels, else none (mask coordinates are then this frame's
|
||||
/// normalized coordinates).
|
||||
pub fn mask_warp(&self) -> Option<&Warp> {
|
||||
self.warp.as_ref().filter(|w| self.masks_on_photo && w.moves_pixels())
|
||||
}
|
||||
|
||||
/// Output pixel → mask coordinates (see [`Frame::masks_on_photo`]).
|
||||
pub fn out_to_mask(&self, out_w: usize, out_h: usize) -> MaskMap {
|
||||
MaskMap {
|
||||
o2t: self.out_to_oriented(out_w, out_h),
|
||||
to_norm: self.out_to_norm(out_w, out_h),
|
||||
ow: self.ow,
|
||||
oh: self.oh,
|
||||
warp: self.mask_warp().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mask coordinates → output pixel (the inverse of [`Frame::out_to_mask`]); `None` for a
|
||||
/// point the warp can't invert (far outside a strong one), which has no place in the output.
|
||||
pub fn mask_to_out(&self, out_w: usize, out_h: usize) -> impl Fn(Point) -> Option<Point> + use<> {
|
||||
let to_out = self.norm_to_out(out_w, out_h);
|
||||
let (warp, ow, oh) = (self.mask_warp().cloned(), self.ow, self.oh);
|
||||
move |n: Point| match &warp {
|
||||
Some(w) => w.from_source(Point::new(n.x * ow, n.y * oh)).map(|t| to_out.apply(Point::new(t.x / ow, t.y / oh))),
|
||||
None => Some(to_out.apply(n)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized coordinates of this frame's transformed image → mask coordinates.
|
||||
pub fn norm_to_mask(&self, n: Point) -> Point {
|
||||
match self.mask_warp() {
|
||||
Some(w) => {
|
||||
let s = w.to_source(Point::new(n.x * self.ow, n.y * self.oh), 1);
|
||||
Point::new(s.x / self.ow, s.y / self.oh)
|
||||
}
|
||||
None => n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mask coordinates → normalized coordinates of this frame's transformed image.
|
||||
pub fn mask_to_norm(&self, n: Point) -> Point {
|
||||
mask_to_norm(self.mask_warp(), self.ow, self.oh, n)
|
||||
}
|
||||
|
||||
/// Convert normalized oriented coordinates to "long-edge units" (isotropic), used by mask shapes.
|
||||
pub fn norm_to_long(&self, p: Point) -> Point {
|
||||
let l = self.ow.max(self.oh);
|
||||
@@ -287,6 +366,40 @@ impl Frame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mask coordinates `n` (on the photo before `warp`, normalized to `ow × oh`) → normalized
|
||||
/// transformed coordinates. A point the warp can't invert (far outside a strong one) stays put.
|
||||
fn mask_to_norm(warp: Option<&Warp>, ow: f64, oh: f64, n: Point) -> Point {
|
||||
match warp.and_then(|w| w.from_source(Point::new(n.x * ow, n.y * oh))) {
|
||||
Some(t) => Point::new(t.x / ow, t.y / oh),
|
||||
None => n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Output pixel → mask coordinates of a frame ([`Frame::out_to_mask`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MaskMap {
|
||||
/// Output px → transformed px, and → normalized transformed coordinates.
|
||||
o2t: Affine,
|
||||
to_norm: Affine,
|
||||
ow: f64,
|
||||
oh: f64,
|
||||
warp: Option<Warp>,
|
||||
}
|
||||
|
||||
impl MaskMap {
|
||||
/// The mask coordinates at output position `p` (px; pixel centres at `x + 0.5`).
|
||||
#[inline]
|
||||
pub fn apply(&self, p: Point) -> Point {
|
||||
match &self.warp {
|
||||
Some(w) => {
|
||||
let s = w.to_source(self.o2t.apply(p), 1);
|
||||
Point::new(s.x / self.ow, s.y / self.oh)
|
||||
}
|
||||
None => self.to_norm.apply(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a render resamples its source (see [`Frame::sample_plan`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SamplePlan {
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod lens_blur;
|
||||
pub mod local;
|
||||
pub mod local_tone;
|
||||
pub mod lut;
|
||||
pub mod mask_space;
|
||||
pub mod masks;
|
||||
pub mod optics;
|
||||
pub mod output;
|
||||
@@ -253,6 +254,10 @@ pub fn frame_for(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, apply_cro
|
||||
let mut frame = geometry::Frame::with_lens(src.width, src.height, s, apply_crop, info.lens.as_ref());
|
||||
if s.optics.remove_ca && s.section_enabled("optics") {
|
||||
frame.add_lateral_ca(optics::estimate_lateral_ca(src));
|
||||
if apply_crop {
|
||||
// The estimated CA moves the colour planes too: keep the crop inside every one of them.
|
||||
frame.constrain_to_image(s.geometry.constrain_crop);
|
||||
}
|
||||
}
|
||||
frame
|
||||
}
|
||||
@@ -787,6 +792,8 @@ mod tests_hdr;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
#[cfg(test)]
|
||||
mod tests_mask_geometry;
|
||||
#[cfg(test)]
|
||||
mod tests_nr_scale;
|
||||
#[cfg(test)]
|
||||
mod tests_tone;
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Moving a mask's coordinates between frames: [`attach`] puts an old mask's coordinates (on the
|
||||
//! corrected frame) on the photo before the warp ([`lightcraft_develop::Mask::on_photo`]), and
|
||||
//! [`turn`] follows a quarter turn or flip of the photo (Rotate Left / Right). Gradients, brush
|
||||
//! strokes and clicks move point by point; stored AI segmentations are resampled.
|
||||
|
||||
use lightcraft_develop::{Mask, MaskShape, SegMask};
|
||||
use lightcraft_geom::{Orientation, Point};
|
||||
|
||||
use crate::geometry::Frame;
|
||||
use crate::masks::SegGrid;
|
||||
|
||||
/// Put mask `m`, drawn on the corrected (transformed) image of `frame`, on the photo before the
|
||||
/// warp: it then renders where it did under `frame` and stays on that content whatever lens
|
||||
/// corrections, Upright or Transform do later. A mask already on the photo is left alone.
|
||||
///
|
||||
/// Points move exactly; a gradient's shape (a radial's radii and angle, a linear's direction and
|
||||
/// width) goes through the warp's local linear part at the gradient, so it covers the same region
|
||||
/// as long as the warp is close to linear across it (lens and perspective warps are smooth).
|
||||
pub fn attach(m: &mut Mask, frame: &Frame) {
|
||||
if m.on_photo {
|
||||
return;
|
||||
}
|
||||
m.on_photo = true;
|
||||
let f = frame.for_mask(true);
|
||||
if f.mask_warp().is_none() {
|
||||
// without a warp both are the same coordinates
|
||||
return;
|
||||
}
|
||||
let fwd = |p: Point| f.norm_to_mask(p);
|
||||
let back = |p: Point| f.mask_to_norm(p);
|
||||
let l = f.ow.max(f.oh).max(1e-9);
|
||||
let (sx, sy) = (f.ow / l, f.oh / l);
|
||||
for c in &mut m.components {
|
||||
match &mut c.shape {
|
||||
MaskShape::Linear { start, end } => (*start, *end) = attach_linear(*start, *end, &fwd, sx, sy),
|
||||
MaskShape::Radial { center, rx, ry, angle, .. } => {
|
||||
let mid = *center;
|
||||
*center = fwd(mid);
|
||||
if let Some(j) = jacobian(&fwd, mid, sx, sy)
|
||||
&& let Some((nrx, nry, na)) = attach_ellipse(j, *rx, *ry, *angle)
|
||||
{
|
||||
(*rx, *ry, *angle) = (nrx, nry, na);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
remap(m, &fwd, &back, 0.0, false);
|
||||
}
|
||||
|
||||
/// The local linear part of `f` (normalized → normalized) at `p`, in long-edge units (isotropic;
|
||||
/// `sx`, `sy` are the frame's sides over its long side): `[a, b, c, d]` takes `(dx, dy)` to
|
||||
/// `(a dx + b dy, c dx + d dy)`. `None` where `f` isn't finite or collapses.
|
||||
fn jacobian(f: &dyn Fn(Point) -> Point, p: Point, sx: f64, sy: f64) -> Option<[f64; 4]> {
|
||||
const H: f64 = 1e-4;
|
||||
let (o, px, py) = (f(p), f(Point::new(p.x + H, p.y)), f(Point::new(p.x, p.y + H)));
|
||||
let j = [(px.x - o.x) / H, (py.x - o.x) * sx / (H * sy), (px.y - o.y) * sy / (H * sx), (py.y - o.y) / H];
|
||||
let det = j[0] * j[3] - j[1] * j[2];
|
||||
(j.iter().all(|v| v.is_finite()) && det.is_finite() && det.abs() > 1e-9).then_some(j)
|
||||
}
|
||||
|
||||
/// A linear gradient from `start` to `end` (normalized, on the corrected frame) on the photo:
|
||||
/// its start moves through `f`, and its direction and length follow the local linear part, so
|
||||
/// its bands (lines at right angles to it) land on the same content. The end moves through `f`
|
||||
/// alone where that part isn't defined.
|
||||
fn attach_linear(start: Point, end: Point, f: &dyn Fn(Point) -> Point, sx: f64, sy: f64) -> (Point, Point) {
|
||||
let (a, plain_end) = (f(start), f(end));
|
||||
let mid = Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0);
|
||||
let Some([ja, jb, jc, jd]) = jacobian(f, mid, sx, sy) else { return (a, plain_end) };
|
||||
// the bands are level sets of (p - start)·d / |d|²: through J they keep that form with the
|
||||
// normal J^-T d, and the same |d|² sets the length
|
||||
let d = ((end.x - start.x) * sx, (end.y - start.y) * sy);
|
||||
let det = ja * jd - jb * jc;
|
||||
let n = ((jd * d.0 - jc * d.1) / det, (-jb * d.0 + ja * d.1) / det);
|
||||
let (dd, nn) = (d.0 * d.0 + d.1 * d.1, n.0 * n.0 + n.1 * n.1);
|
||||
if !(dd.is_finite() && nn.is_finite()) || nn < 1e-18 {
|
||||
return (a, plain_end);
|
||||
}
|
||||
let k = dd / nn;
|
||||
let e = Point::new(a.x + n.0 * k / sx, a.y + n.1 * k / sy);
|
||||
if e.x.is_finite() && e.y.is_finite() { (a, e) } else { (a, plain_end) }
|
||||
}
|
||||
|
||||
/// An ellipse of radii `rx`, `ry` (long-edge units) turned by `angle` degrees, through the linear
|
||||
/// map `j` (long-edge units): the radii and angle of the ellipse it becomes, `rx` staying the
|
||||
/// radius along the image of the old `rx` axis. `None` when the result isn't usable.
|
||||
fn attach_ellipse(j: [f64; 4], rx: f64, ry: f64, angle: f64) -> Option<(f64, f64, f64)> {
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
// M = J · R(angle) · diag(rx, ry): its columns are the images of the two semi-axes
|
||||
let m = [(j[0] * c + j[1] * s) * rx, (j[1] * c - j[0] * s) * ry, (j[2] * c + j[3] * s) * rx, (j[3] * c - j[2] * s) * ry];
|
||||
// the new ellipse's axes: eigenvectors of M Mᵀ, its radii the square roots of the eigenvalues
|
||||
let (p, q, r) = (m[0] * m[0] + m[1] * m[1], m[0] * m[2] + m[1] * m[3], m[2] * m[2] + m[3] * m[3]);
|
||||
let (half, det) = ((p + r) / 2.0, p * r - q * q);
|
||||
let root = (half * half - det).max(0.0).sqrt();
|
||||
let (l1, l2) = (half + root, (half - root).max(0.0));
|
||||
let v1 = if q.abs() > 1e-15 * (p.abs() + r.abs()) {
|
||||
let (x, y) = (q, l1 - p);
|
||||
let n = x.hypot(y);
|
||||
(x / n, y / n)
|
||||
} else if p >= r {
|
||||
(1.0, 0.0)
|
||||
} else {
|
||||
(0.0, 1.0)
|
||||
};
|
||||
let v2 = (-v1.1, v1.0);
|
||||
// the old rx axis, mapped
|
||||
let a = (m[0], m[2]);
|
||||
let along1 = (a.0 * v1.0 + a.1 * v1.1).abs() >= (a.0 * v2.0 + a.1 * v2.1).abs();
|
||||
let (axis, nrx, nry) = if along1 { (v1, l1.sqrt(), l2.sqrt()) } else { (v2, l2.sqrt(), l1.sqrt()) };
|
||||
// (pointing the way the old axis went)
|
||||
let axis = if axis.0 * a.0 + axis.1 * a.1 < 0.0 { (-axis.0, -axis.1) } else { axis };
|
||||
let na = axis.1.atan2(axis.0).to_degrees();
|
||||
(nrx.is_finite() && nry.is_finite() && na.is_finite() && nrx > 0.0 && nry > 0.0).then_some((nrx, nry, na))
|
||||
}
|
||||
|
||||
/// Follow `turn` (the change of the user orientation, e.g. a quarter turn clockwise): mask
|
||||
/// coordinates are on the oriented photo, so they turn with it. Radial angles turn by the same
|
||||
/// quarter turns; sizes (long-edge units) don't change.
|
||||
pub fn turn(m: &mut Mask, turn: Orientation) {
|
||||
if turn == Orientation::Normal {
|
||||
return;
|
||||
}
|
||||
let fwd = |p: Point| {
|
||||
let (x, y) = turn.map(p.x, p.y, 1.0, 1.0);
|
||||
Point::new(x, y)
|
||||
};
|
||||
let inv = turn.inverse();
|
||||
let back = |p: Point| {
|
||||
let (x, y) = inv.map(p.x, p.y, 1.0, 1.0);
|
||||
Point::new(x, y)
|
||||
};
|
||||
let (flip, quarters) = turn.to_parts();
|
||||
// a radial's angle is clockwise on screen: a quarter turn clockwise adds 90°; a mirror negates it first
|
||||
let delta = 90.0 * quarters as f64;
|
||||
if flip {
|
||||
for c in &mut m.components {
|
||||
if let MaskShape::Radial { angle, .. } = &mut c.shape {
|
||||
*angle = -*angle;
|
||||
}
|
||||
}
|
||||
}
|
||||
remap(m, &fwd, &back, delta, true);
|
||||
}
|
||||
|
||||
/// Move every place `m` stores: `fwd` maps an old point to the new frame, `back` a new point to
|
||||
/// the old one (for resampling segmentations); radial angles change by `delta` degrees. With
|
||||
/// `gradients` false, linear and radial gradients are left to the caller.
|
||||
fn remap(m: &mut Mask, fwd: &dyn Fn(Point) -> Point, back: &dyn Fn(Point) -> Point, delta: f64, gradients: bool) {
|
||||
for c in &mut m.components {
|
||||
match &mut c.shape {
|
||||
MaskShape::Linear { start, end } if gradients => {
|
||||
*start = fwd(*start);
|
||||
*end = fwd(*end);
|
||||
}
|
||||
MaskShape::Radial { center, angle, .. } if gradients => {
|
||||
*center = fwd(*center);
|
||||
if delta != 0.0 {
|
||||
// (kept within −180..180°)
|
||||
*angle = (*angle + delta + 180.0).rem_euclid(360.0) - 180.0;
|
||||
}
|
||||
}
|
||||
MaskShape::Linear { .. } | MaskShape::Radial { .. } => {}
|
||||
MaskShape::Brush { strokes } => {
|
||||
for s in strokes {
|
||||
s.points.iter_mut().for_each(|p| *p = fwd(*p));
|
||||
}
|
||||
}
|
||||
MaskShape::Object { hint, exclude, seg, detail, .. } => {
|
||||
hint.iter_mut().chain(exclude.iter_mut()).for_each(|p| *p = fwd(*p));
|
||||
remap_segs(seg, detail, fwd, back);
|
||||
}
|
||||
MaskShape::Prompt { seg, detail, .. } => remap_segs(seg, detail, fwd, back),
|
||||
// computed from the photo at each render (its mattes follow the warp), or colours,
|
||||
// tones and depths, which have no place (a colour range's samples are colours)
|
||||
MaskShape::Subject
|
||||
| MaskShape::Sky
|
||||
| MaskShape::Background
|
||||
| MaskShape::People { .. }
|
||||
| MaskShape::Landscape { .. }
|
||||
| MaskShape::ColorRange { .. }
|
||||
| MaskShape::LuminanceRange { .. }
|
||||
| MaskShape::DepthRange { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored selection and its zoomed-in passes, resampled onto the new frame. Where a pass's new
|
||||
/// box reaches past what it covered, it takes the whole-photo selection there (what the render
|
||||
/// showed), not a copy of its own edge.
|
||||
fn remap_segs(seg: &mut Option<SegMask>, detail: &mut [SegMask], fwd: &dyn Fn(Point) -> Point, back: &dyn Fn(Point) -> Point) {
|
||||
let coarse = seg.as_ref().and_then(SegGrid::new);
|
||||
for d in detail.iter_mut() {
|
||||
if let Some(m) = moved(d, fwd, back, coarse.as_ref()) {
|
||||
*d = m;
|
||||
}
|
||||
}
|
||||
if let Some(s) = seg.as_ref()
|
||||
&& let Some(m) = moved(s, fwd, back, None)
|
||||
{
|
||||
*seg = Some(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Points along each edge of a box [`map_rect`] maps: a lens warp bends an edge between its ends,
|
||||
/// most where it crosses the centre lines.
|
||||
const RECT_EDGE_SAMPLES: usize = 16;
|
||||
|
||||
/// The box around `r` mapped by `f` (points along its edges), inside 0..1.
|
||||
fn map_rect(r: [f64; 4], f: &dyn Fn(Point) -> Point) -> [f64; 4] {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for i in 0..=RECT_EDGE_SAMPLES {
|
||||
let t = i as f64 / RECT_EDGE_SAMPLES as f64;
|
||||
let (x, y) = (r[0] + t * (r[2] - r[0]), r[1] + t * (r[3] - r[1]));
|
||||
for q in [(x, r[1]), (x, r[3]), (r[0], y), (r[2], y)] {
|
||||
let p = f(Point::new(q.0, q.1));
|
||||
if p.x.is_finite() && p.y.is_finite() {
|
||||
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
if x0 > x1 || y0 > y1 {
|
||||
return r;
|
||||
}
|
||||
[x0.clamp(0.0, 1.0), y0.clamp(0.0, 1.0), x1.clamp(0.0, 1.0), y1.clamp(0.0, 1.0)]
|
||||
}
|
||||
|
||||
/// Segmentation `s` resampled onto the new frame: a grid of the same side over the mapped box
|
||||
/// (the whole image for a whole-image one), each cell taking the old logit at its centre mapped
|
||||
/// back. A cell whose centre maps back outside what `s` covered takes `outside`'s logit there
|
||||
/// (for a zoomed-in pass: the whole-photo selection), else selects nothing. `None` when the data
|
||||
/// is damaged (it is kept as it is).
|
||||
fn moved(s: &SegMask, fwd: &dyn Fn(Point) -> Point, back: &dyn Fn(Point) -> Point, outside: Option<&SegGrid>) -> Option<SegMask> {
|
||||
let old = SegGrid::new(s)?;
|
||||
let side = s.side as usize;
|
||||
let rect = match s.rect {
|
||||
None => None,
|
||||
Some(r) => Some(map_rect(r, fwd)).filter(|b| b[2] > b[0] && b[3] > b[1]),
|
||||
};
|
||||
let b = rect.unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
||||
let logits: Vec<f32> = (0..side * side)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % side) as f64 + 0.5, (i / side) as f64 + 0.5);
|
||||
let n = Point::new(b[0] + x / side as f64 * (b[2] - b[0]), b[1] + y / side as f64 * (b[3] - b[1]));
|
||||
let o = back(n);
|
||||
if old.contains(o) { old.logit(o) } else { outside.map_or(-16.0, |g| g.logit(o)) }
|
||||
})
|
||||
.collect();
|
||||
Some(match rect {
|
||||
Some(r) => SegMask::from_logits_in(side, &logits, r),
|
||||
None => SegMask::from_logits(side, &logits),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_develop::{BrushStroke, DevelopSettings, MaskComponent, MaskOp};
|
||||
|
||||
fn comp(shape: MaskShape) -> MaskComponent {
|
||||
MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }
|
||||
}
|
||||
|
||||
fn mask(shape: MaskShape) -> Mask {
|
||||
Mask { components: vec![comp(shape)], ..Default::default() }
|
||||
}
|
||||
|
||||
fn seg_at(p: Point) -> SegMask {
|
||||
const SIDE: usize = 32;
|
||||
let l: Vec<f32> = (0..SIDE * SIDE)
|
||||
.map(|i| {
|
||||
let (x, y) = (((i % SIDE) as f64 + 0.5) / SIDE as f64, ((i / SIDE) as f64 + 0.5) / SIDE as f64);
|
||||
if (x - p.x).hypot(y - p.y) < 0.08 { 8.0 } else { -8.0 }
|
||||
})
|
||||
.collect();
|
||||
SegMask::from_logits(SIDE, &l)
|
||||
}
|
||||
|
||||
fn object_at(p: Point) -> MaskShape {
|
||||
MaskShape::Object { hint: vec![p], exclude: Vec::new(), seg: Some(seg_at(p)), detail: Vec::new(), edge: 0.0 }
|
||||
}
|
||||
|
||||
/// The centre of what `s` selects (sampled on a fine grid).
|
||||
fn centre_of(s: &SegMask) -> Point {
|
||||
let g = SegGrid::new(s).unwrap();
|
||||
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0);
|
||||
for i in 0..200 * 200 {
|
||||
let p = Point::new(((i % 200) as f64 + 0.5) / 200.0, ((i / 200) as f64 + 0.5) / 200.0);
|
||||
if g.contains(p) && g.logit(p) > 0.0 {
|
||||
(sx, sy, n) = (sx + p.x, sy + p.y, n + 1.0);
|
||||
}
|
||||
}
|
||||
assert!(n > 0.0, "nothing selected");
|
||||
Point::new(sx / n, sy / n)
|
||||
}
|
||||
|
||||
fn seg_of(m: &Mask, i: usize) -> &SegMask {
|
||||
match &m.components[i].shape {
|
||||
MaskShape::Object { seg: Some(s), .. } => s,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_turns_every_place_a_mask_keeps() {
|
||||
let p = Point::new(0.2, 0.3);
|
||||
let mut m = mask(MaskShape::Radial { center: p, rx: 0.1, ry: 0.05, angle: 10.0, feather: 50.0, invert: false });
|
||||
m.components.push(comp(MaskShape::Brush { strokes: vec![BrushStroke { points: vec![p], ..Default::default() }] }));
|
||||
m.components.push(comp(object_at(p)));
|
||||
turn(&mut m, Orientation::Rotate90);
|
||||
// a quarter turn clockwise: (x, y) → (1 − y, x)
|
||||
let want = Point::new(0.7, 0.2);
|
||||
let MaskShape::Radial { center, angle, .. } = &m.components[0].shape else { panic!() };
|
||||
assert!((center.x - want.x).abs() < 1e-9 && (center.y - want.y).abs() < 1e-9, "{center:?}");
|
||||
assert_eq!(*angle, 100.0);
|
||||
let MaskShape::Brush { strokes } = &m.components[1].shape else { panic!() };
|
||||
assert!((strokes[0].points[0].x - want.x).abs() < 1e-9);
|
||||
let MaskShape::Object { hint, .. } = &m.components[2].shape else { panic!() };
|
||||
assert!((hint[0].x - want.x).abs() < 1e-9 && (hint[0].y - want.y).abs() < 1e-9, "{hint:?}");
|
||||
let c = centre_of(seg_of(&m, 2));
|
||||
assert!((c.x - want.x).abs() < 0.04 && (c.y - want.y).abs() < 0.04, "{c:?}");
|
||||
// and back
|
||||
turn(&mut m, Orientation::Rotate270);
|
||||
let MaskShape::Radial { center, angle, .. } = &m.components[0].shape else { panic!() };
|
||||
assert!((center.x - p.x).abs() < 1e-9 && (center.y - p.y).abs() < 1e-9 && *angle == 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_an_old_mask_keeps_where_it_renders() {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.geometry.vertical = 30.0;
|
||||
s.geometry.rotate = 5.0;
|
||||
let frame = Frame::new(300, 200, &s, false);
|
||||
let p = Point::new(0.3, 0.4);
|
||||
let mut m = mask(MaskShape::Linear { start: p, end: Point::new(0.6, 0.4) });
|
||||
m.components.push(comp(object_at(p)));
|
||||
m.on_photo = false;
|
||||
attach(&mut m, &frame);
|
||||
assert!(m.on_photo);
|
||||
let on = frame.for_mask(true);
|
||||
let MaskShape::Linear { start, .. } = &m.components[0].shape else { panic!() };
|
||||
let back = on.mask_to_norm(*start);
|
||||
assert!((back.x - p.x).abs() < 1e-6 && (back.y - p.y).abs() < 1e-6, "{back:?}");
|
||||
assert!((start.x - p.x).hypot(start.y - p.y) > 0.005, "the warp moved it on the photo: {start:?}");
|
||||
let c = on.mask_to_norm(centre_of(seg_of(&m, 1)));
|
||||
assert!((c.x - p.x).abs() < 0.04 && (c.y - p.y).abs() < 0.04, "{c:?}");
|
||||
// attaching again changes nothing
|
||||
let before = m.clone();
|
||||
attach(&mut m, &frame);
|
||||
assert_eq!(m, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_or_hostile_masks_move_without_a_panic() {
|
||||
let mut m =
|
||||
mask(MaskShape::Radial { center: Point::new(f64::NAN, f64::INFINITY), rx: 0.1, ry: 0.1, angle: f64::NAN, feather: 0.0, invert: false });
|
||||
m.components.push(comp(MaskShape::Object {
|
||||
hint: vec![Point::new(1e300, -1e300)],
|
||||
exclude: Vec::new(),
|
||||
seg: Some(SegMask { side: 1024, data: "not base64".into(), rect: Some([f64::NAN, 0.0, 2.0, -1.0]) }),
|
||||
detail: vec![SegMask { side: 0, data: String::new(), rect: None }],
|
||||
edge: 0.0,
|
||||
}));
|
||||
m.on_photo = false;
|
||||
let mut s = DevelopSettings::default();
|
||||
s.geometry.vertical = 80.0;
|
||||
s.optics.distortion = 100.0;
|
||||
attach(&mut m, &Frame::new(300, 200, &s, false));
|
||||
for o in [Orientation::Rotate90, Orientation::FlipH, Orientation::Rotate180] {
|
||||
turn(&mut m, o);
|
||||
}
|
||||
assert!(m.on_photo);
|
||||
}
|
||||
|
||||
/// A Transform and lens warp strong enough to bend shapes across a gradient.
|
||||
fn warped() -> DevelopSettings {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.geometry.vertical = 40.0;
|
||||
s.geometry.horizontal = -25.0;
|
||||
s.geometry.rotate = 8.0;
|
||||
s.optics.distortion = 60.0;
|
||||
s
|
||||
}
|
||||
|
||||
/// The mean and largest difference between mask `a` and `b` rendered under `s`.
|
||||
fn alpha_diff(a: &Mask, b: &Mask, s: &DevelopSettings) -> (f32, f32) {
|
||||
let frame = Frame::new(300, 200, s, true);
|
||||
let (w, h) = frame.fit(300, 200);
|
||||
let img = lightcraft_raster::Rgb32f::new(w, h);
|
||||
let log_l = lightcraft_raster::Plane::new(w, h);
|
||||
let pa = crate::masks::evaluate_one(a, &frame, w, h, &img, &log_l, 0.0, None);
|
||||
let pb = crate::masks::evaluate_one(b, &frame, w, h, &img, &log_l, 0.0, None);
|
||||
let d: Vec<f32> = pa.data.iter().zip(&pb.data).map(|(x, y)| (x - y).abs()).collect();
|
||||
(d.iter().sum::<f32>() / d.len().max(1) as f32, d.iter().copied().fold(0.0, f32::max))
|
||||
}
|
||||
|
||||
/// An old gradient keeps its region when it goes onto the photo: its shape goes through the
|
||||
/// warp's local linear part, not only its centre or ends (review of #3550).
|
||||
#[test]
|
||||
fn attaching_an_old_gradient_keeps_its_region() {
|
||||
let s = warped();
|
||||
let frame = Frame::new(300, 200, &s, false);
|
||||
for shape in [
|
||||
MaskShape::Radial { center: Point::new(0.3, 0.35), rx: 0.18, ry: 0.07, angle: 30.0, feather: 30.0, invert: false },
|
||||
MaskShape::Linear { start: Point::new(0.2, 0.25), end: Point::new(0.45, 0.6) },
|
||||
] {
|
||||
let mut old = mask(shape.clone());
|
||||
old.on_photo = false;
|
||||
let mut on = old.clone();
|
||||
attach(&mut on, &frame);
|
||||
// what moving the points alone would give
|
||||
let mut naive = old.clone();
|
||||
naive.on_photo = true;
|
||||
remap(&mut naive, &|p| frame.for_mask(true).norm_to_mask(p), &|p| frame.for_mask(true).mask_to_norm(p), 0.0, true);
|
||||
let (mean, max) = alpha_diff(&old, &on, &s);
|
||||
let (naive_mean, naive_max) = alpha_diff(&old, &naive, &s);
|
||||
// (the warp isn't linear across the whole gradient: a little is left at its far edge)
|
||||
assert!(mean < 0.01, "{shape:?}: mean {mean}, max {max}");
|
||||
assert!(naive_mean > 1.5 * mean && naive_max > 2.0 * max, "{shape:?}: points alone {naive_mean} / {naive_max} vs {mean} / {max}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a zoomed-in pass's new box reaches past what it covered (a turned box's corners), it
|
||||
/// takes the whole-photo selection: neither a copy of its own edge (a negative whole-photo
|
||||
/// selection) nor a fixed "unselected" (a positive one) (review of #3550).
|
||||
#[test]
|
||||
fn a_moved_detail_pass_takes_the_whole_selection_past_its_own_area() {
|
||||
const SIDE: usize = 16;
|
||||
let mut s = DevelopSettings::default();
|
||||
s.geometry.rotate = 20.0;
|
||||
let frame = Frame::new(300, 200, &s, false);
|
||||
for whole in [8.0f32, -8.0] {
|
||||
let detail = SegMask::from_logits_in(SIDE, &[8.0; SIDE * SIDE], [0.4, 0.4, 0.6, 0.6]);
|
||||
let coarse = SegMask::from_logits(SIDE, &[whole; SIDE * SIDE]);
|
||||
let mut m =
|
||||
mask(MaskShape::Object { hint: vec![Point::new(0.5, 0.5)], exclude: Vec::new(), seg: Some(coarse), detail: vec![detail], edge: 0.0 });
|
||||
m.on_photo = false;
|
||||
attach(&mut m, &frame);
|
||||
let MaskShape::Object { detail, .. } = &m.components[0].shape else { panic!() };
|
||||
let g = SegGrid::new(&detail[0]).unwrap();
|
||||
let b = detail[0].bounds().unwrap();
|
||||
// a corner of the new box is outside the turned patch: the whole-photo selection's sign
|
||||
let inset = |t: f64| Point::new(b[0] + t * (b[2] - b[0]), b[1] + t * (b[3] - b[1]));
|
||||
let corner = g.logit(inset(0.03));
|
||||
assert!((corner > 0.0) == (whole > 0.0), "whole {whole}: corner {corner}");
|
||||
assert!(g.logit(inset(0.5)) > 0.0, "whole {whole}: the patch itself stays selected");
|
||||
}
|
||||
}
|
||||
|
||||
/// The box around a mapped rectangle holds the whole bent edge, also off the centre lines
|
||||
/// (review of #3550).
|
||||
#[test]
|
||||
fn a_mapped_box_holds_its_bent_edges() {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.distortion = -80.0;
|
||||
let f = Frame::new(300, 200, &s, false).for_mask(true).into_owned();
|
||||
let fwd = |p: Point| f.norm_to_mask(p);
|
||||
let r = [0.2, 0.1, 0.9, 0.4];
|
||||
let b = map_rect(r, &fwd);
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for i in 0..=2000 {
|
||||
let t = i as f64 / 2000.0;
|
||||
let (x, y) = (r[0] + t * (r[2] - r[0]), r[1] + t * (r[3] - r[1]));
|
||||
for q in [(x, r[1]), (x, r[3]), (r[0], y), (r[2], y)] {
|
||||
let p = fwd(Point::new(q.0, q.1));
|
||||
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
|
||||
}
|
||||
}
|
||||
let want = [x0.clamp(0.0, 1.0), y0.clamp(0.0, 1.0), x1.clamp(0.0, 1.0), y1.clamp(0.0, 1.0)];
|
||||
for k in 0..4 {
|
||||
assert!((b[k] - want[k]).abs() < 5e-4, "{b:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,8 @@ pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f,
|
||||
/// scaled by its amount.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32, mattes: Option<&Mattes>) -> Plane {
|
||||
// its coordinates on the photo before the warp, or (an old mask) on the corrected frame
|
||||
let frame = &*frame.for_mask(m.on_photo);
|
||||
let mut alpha = Plane::new(w, h);
|
||||
let mut first = true;
|
||||
for comp in &m.components {
|
||||
@@ -201,7 +203,7 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l
|
||||
|
||||
/// Per-pixel positions in long-edge units for output pixel centres.
|
||||
fn for_each_pos(frame: &Frame, w: usize, h: usize, out: &mut Plane, f: impl Fn(Point, usize) -> f32 + Sync + Send) {
|
||||
let m = frame.out_to_norm(w, h);
|
||||
let m = frame.out_to_mask(w, h);
|
||||
let (ow, oh, l) = (frame.ow, frame.oh, frame.ow.max(frame.oh));
|
||||
for_rows(&mut out.data, w, |y, row| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
@@ -272,7 +274,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
||||
MaskShape::Sky => {
|
||||
// Classical sky heuristic until the segmenter lands (M12): bright, smooth, blue-ish or
|
||||
// unsaturated, and connected to the top of the frame.
|
||||
let m = frame.out_to_norm(w, h);
|
||||
let m = frame.out_to_mask(w, h);
|
||||
for (i, v) in out.data.iter_mut().enumerate() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
@@ -299,7 +301,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
||||
MaskShape::Prompt { .. } => {}
|
||||
MaskShape::Subject | MaskShape::Object { .. } | MaskShape::People { .. } => {
|
||||
// Saliency heuristic: centre-weighted local contrast (replaced by the segmenter in M12).
|
||||
let m = frame.out_to_norm(w, h);
|
||||
let m = frame.out_to_mask(w, h);
|
||||
let blur = lightcraft_raster::blur::gaussian(log_l, 0.03 * frame_px(frame, w));
|
||||
for (i, v) in out.data.iter_mut().enumerate() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
@@ -321,24 +323,24 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
||||
}
|
||||
|
||||
/// A decoded [`SegMask`]: its logits and the normalized rectangle they cover.
|
||||
struct SegGrid {
|
||||
pub struct SegGrid {
|
||||
logits: Vec<f32>,
|
||||
side: usize,
|
||||
r: [f64; 4],
|
||||
}
|
||||
|
||||
impl SegGrid {
|
||||
fn new(seg: &SegMask) -> Option<SegGrid> {
|
||||
pub fn new(seg: &SegMask) -> Option<SegGrid> {
|
||||
Some(SegGrid { logits: seg.logits()?, side: seg.side as usize, r: seg.bounds()? })
|
||||
}
|
||||
|
||||
fn contains(&self, n: Point) -> bool {
|
||||
pub fn contains(&self, n: Point) -> bool {
|
||||
n.x >= self.r[0] && n.x <= self.r[2] && n.y >= self.r[1] && n.y <= self.r[3]
|
||||
}
|
||||
|
||||
/// The bilinearly sampled logit at normalized image point `n` (cell centres at
|
||||
/// `(i + 0.5) / side` of the rectangle).
|
||||
fn logit(&self, n: Point) -> f32 {
|
||||
pub fn logit(&self, n: Point) -> f32 {
|
||||
let side = self.side;
|
||||
let at = |x: usize, y: usize| self.logits.get(y.min(side - 1) * side + x.min(side - 1)).copied().unwrap_or(-16.0);
|
||||
let u = (n.x - self.r[0]) / (self.r[2] - self.r[0]);
|
||||
@@ -391,7 +393,7 @@ fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Fram
|
||||
if coarse.is_none() && patches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let m = frame.out_to_norm(w, h);
|
||||
let m = frame.out_to_mask(w, h);
|
||||
for_rows(&mut out.data, w, |y, row| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
@@ -577,7 +579,7 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa
|
||||
/// Ask for one more than the threshold to tell "at it" from "past it": a result shorter than
|
||||
/// `max` is the whole stroke.
|
||||
pub fn brush_dabs_within(s: &BrushStroke, frame: &Frame, w: usize, h: usize, max: usize) -> BrushDabs {
|
||||
let to_out = frame.norm_to_out(w, h);
|
||||
let to_out = frame.mask_to_out(w, h);
|
||||
let ppl = frame.px_per_long(w);
|
||||
let r = (s.size * ppl).max(0.5);
|
||||
let hard = r * (1.0 - (s.feather / 100.0).clamp(0.0, 1.0));
|
||||
@@ -594,7 +596,8 @@ pub fn brush_dabs_within(s: &BrushStroke, frame: &Frame, w: usize, h: usize, max
|
||||
if emitted >= max {
|
||||
break;
|
||||
}
|
||||
let q = to_out.apply(*p);
|
||||
// (a point the warp can't place lays no dab, rather than one at its unwarped place)
|
||||
let Some(q) = to_out(*p) else { continue };
|
||||
let before = dabs.len();
|
||||
if push_dabs_within(&mut dabs, &mut emitted, prev, q, r, max) {
|
||||
let pq = s.pressure_at(i);
|
||||
|
||||
@@ -201,6 +201,39 @@ impl Warp {
|
||||
self.corrected_to_source(self.to_corrected(p), ch)
|
||||
}
|
||||
|
||||
/// Source px → transformed px (green plane): the inverse of [`Warp::to_source`], by Newton's
|
||||
/// method on it (the warps are smooth and close to the identity, so a few steps reach a
|
||||
/// hundredth of a pixel). `None` when it doesn't converge (a point far outside a strong warp).
|
||||
pub fn from_source(&self, s: Point) -> Option<Point> {
|
||||
if !(s.x.is_finite() && s.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let f = |p: Point| self.to_source(p, 1);
|
||||
// start from where the perspective alone puts it (the lens part moves pixels little)
|
||||
let mut t = self.from_corrected(s);
|
||||
let h = 1e-3 * self.w.max(self.h).max(1.0);
|
||||
for _ in 0..24 {
|
||||
let v = f(t);
|
||||
let (ex, ey) = (v.x - s.x, v.y - s.y);
|
||||
if !(ex.is_finite() && ey.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if ex.abs() < 1e-4 && ey.abs() < 1e-4 {
|
||||
return Some(t);
|
||||
}
|
||||
let (dx, dy) = (f(Point::new(t.x + h, t.y)), f(Point::new(t.x, t.y + h)));
|
||||
let (a, c) = ((dx.x - v.x) / h, (dx.y - v.y) / h);
|
||||
let (b, d) = ((dy.x - v.x) / h, (dy.y - v.y) / h);
|
||||
let det = a * d - b * c;
|
||||
if !det.is_finite() || det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
t = Point::new(t.x - (d * ex - b * ey) / det, t.y - (a * ey - c * ex) / det);
|
||||
}
|
||||
let v = f(t);
|
||||
((v.x - s.x).abs() < 1e-2 && (v.y - s.y).abs() < 1e-2).then_some(t)
|
||||
}
|
||||
|
||||
/// Vignetting compensation gain at source position `src` (px).
|
||||
pub fn gain(&self, src: Point) -> f32 {
|
||||
let mut g = 1.0;
|
||||
@@ -375,7 +408,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
|
||||
|
||||
@@ -161,17 +161,18 @@ pub fn window_for_reads_checked(
|
||||
win: crate::PixelWindow,
|
||||
) -> Option<crate::PixelWindow> {
|
||||
let spots = &s.spots;
|
||||
let autos: Vec<&lightcraft_develop::BrushStroke> = s
|
||||
// (with whether its mask's coordinates are on the photo before the warp)
|
||||
let autos: Vec<(bool, &lightcraft_develop::BrushStroke)> = s
|
||||
.masks
|
||||
.iter()
|
||||
.filter(|m| m.visible)
|
||||
.flat_map(|m| &m.components)
|
||||
.filter_map(|c| match &c.shape {
|
||||
lightcraft_develop::MaskShape::Brush { strokes } => Some(strokes),
|
||||
.flat_map(|m| m.components.iter().map(move |c| (m.on_photo, c)))
|
||||
.filter_map(|(on, c)| match &c.shape {
|
||||
lightcraft_develop::MaskShape::Brush { strokes } => Some(strokes.iter().map(move |st| (on, st))),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter(|st| st.auto_mask)
|
||||
.filter(|(_, st)| st.auto_mask)
|
||||
.collect();
|
||||
if spots.is_empty() && autos.is_empty() {
|
||||
return Some(win);
|
||||
@@ -205,10 +206,10 @@ pub fn window_for_reads_checked(
|
||||
// Auto Mask strokes weigh each dab by its similarity to the pixel under the dab's centre and
|
||||
// refine the stroke over a couple of radii: each dab matters to pixels within 2.5 radii
|
||||
let mut dab_boxes: Vec<[f64; 4]> = Vec::new();
|
||||
for st in autos {
|
||||
for (on_photo, st) in autos {
|
||||
// One more than the window takes, so a stroke past it is still recognised as past it
|
||||
// without the other 1.8 million dabs being built first (#1520).
|
||||
let dabs = crate::masks::brush_dabs_within(st, frame, full_w, full_h, MAX_DABS_CONSIDERED + 1);
|
||||
let dabs = crate::masks::brush_dabs_within(st, &frame.for_mask(on_photo), full_w, full_h, MAX_DABS_CONSIDERED + 1);
|
||||
let r = dabs.r;
|
||||
if dabs.dabs.len() > MAX_DABS_CONSIDERED {
|
||||
continue;
|
||||
|
||||
@@ -260,6 +260,26 @@ fn constrain_crop_removes_blank_areas() {
|
||||
assert!(blank_fraction(&c) < 0.002, "constrained: {}", blank_fraction(&c));
|
||||
}
|
||||
|
||||
/// Issue #2361: lens corrections never show the blank past the photo's edge, with or without
|
||||
/// Constrain Crop, in every colour plane; perspective alone still needs Constrain Crop.
|
||||
#[test]
|
||||
fn lens_corrections_leave_no_blank_corners() {
|
||||
let src = Rgb32f::from_fn(300, 200, |x, y| [0.05 + 0.002 * x as f32, 0.1 + 0.002 * y as f32, 0.3]);
|
||||
for (distortion, ca) in [(-80.0, 0.0), (80.0, 0.0), (-40.0, 100.0), (0.0, -100.0)] {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.distortion = distortion;
|
||||
s.optics.ca_red = ca;
|
||||
s.optics.ca_blue = -ca;
|
||||
let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(300, 300)).image;
|
||||
assert!(blank_fraction(&r) < 0.002, "distortion {distortion}, CA {ca}: {}", blank_fraction(&r));
|
||||
}
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.distortion = -60.0;
|
||||
s.geometry.vertical = -60.0;
|
||||
let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(300, 300)).image;
|
||||
assert!(blank_fraction(&r) > 0.03, "the keystone keeps its corners: {}", blank_fraction(&r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_preview_matches_export() {
|
||||
let src = lightcraft_scenes::demo_library()[4].render(960, 640);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Masks stay on the image content under every geometry change: a mask
|
||||
//! drawn around a mark in the photo still brightens that mark after Straighten, a crop, a flip or
|
||||
//! a lens / perspective warp, rendered fresh or through a view's stage cache.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_develop::{BrushStroke, DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, SegMask};
|
||||
use lightcraft_geom::{CropGeometry, Point, Rect};
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
|
||||
use crate::{RenderRequest, SourceInfo, StageCache, render, render_cached};
|
||||
|
||||
const W: usize = 240;
|
||||
const H: usize = 160;
|
||||
/// Where the mark sits, normalized.
|
||||
const MARK: (f64, f64) = (0.3, 0.32);
|
||||
|
||||
/// Mid grey with a small bright mark at [`MARK`].
|
||||
fn marked(mark: bool) -> Rgb32f {
|
||||
let (mx, my) = (MARK.0 * W as f64, MARK.1 * H as f64);
|
||||
Rgb32f::from_fn(W, H, |x, y| {
|
||||
let d = ((x as f64 + 0.5 - mx).powi(2) + (y as f64 + 0.5 - my).powi(2)).sqrt();
|
||||
if d < 2.5 && mark { [0.9; 3] } else { [0.05; 3] }
|
||||
})
|
||||
}
|
||||
|
||||
fn with_mask(shape: MaskShape) -> DevelopSettings {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.masks.push(Mask {
|
||||
id: 1,
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
|
||||
adjust: LocalAdjustments { exposure: 2.0, ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
fn radial() -> MaskShape {
|
||||
let l = W.max(H) as f64;
|
||||
// (radii in long-edge units)
|
||||
MaskShape::Radial { center: Point::new(MARK.0, MARK.1), rx: 12.0 / l, ry: 12.0 / l, angle: 0.0, feather: 40.0, invert: false }
|
||||
}
|
||||
|
||||
/// An AI object selection (a stored segmentation) covering the mark.
|
||||
fn subject() -> MaskShape {
|
||||
const SIDE: usize = 64;
|
||||
let logits: Vec<f32> = (0..SIDE * SIDE)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % SIDE) as f64 + 0.5, (i / SIDE) as f64 + 0.5);
|
||||
let (dx, dy) = ((x / SIDE as f64 - MARK.0) * W as f64, (y / SIDE as f64 - MARK.1) * H as f64);
|
||||
if (dx * dx + dy * dy).sqrt() < 10.0 { 8.0 } else { -8.0 }
|
||||
})
|
||||
.collect();
|
||||
MaskShape::Object {
|
||||
hint: vec![Point::new(MARK.0, MARK.1)],
|
||||
exclude: Vec::new(),
|
||||
seg: Some(SegMask::from_logits(SIDE, &logits)),
|
||||
detail: Vec::new(),
|
||||
edge: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn luma(img: &Rgba8, i: usize) -> f64 {
|
||||
let p = img.data[i];
|
||||
(p[0] as f64 + p[1] as f64 + p[2] as f64) / 3.0
|
||||
}
|
||||
|
||||
/// The mark's centre in a render with `s` (where the marked photo differs from an unmarked one:
|
||||
/// a warp leaves light blank canvas around the photo).
|
||||
fn mark_at(s: &DevelopSettings) -> (f64, f64) {
|
||||
let req = RenderRequest::fit(W, H);
|
||||
let info = SourceInfo::default();
|
||||
let (a, b) = (render(&marked(true), &info, s, &req).image, render(&marked(false), &info, s, &req).image);
|
||||
centroid(a.width, (0..a.data.len()).map(|i| (luma(&a, i) - luma(&b, i)).max(0.0)))
|
||||
}
|
||||
|
||||
fn centroid(w: usize, weights: impl Iterator<Item = f64>) -> (f64, f64) {
|
||||
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0);
|
||||
for (i, v) in weights.enumerate() {
|
||||
sx += v * ((i % w) as f64 + 0.5);
|
||||
sy += v * ((i / w) as f64 + 0.5);
|
||||
n += v;
|
||||
}
|
||||
assert!(n > 0.0, "nothing found");
|
||||
(sx / n, sy / n)
|
||||
}
|
||||
|
||||
/// Where the mask brightened the render (`plain` is the same render without the mask).
|
||||
fn mask_at(masked: &Rgba8, plain: &Rgba8) -> (f64, f64) {
|
||||
centroid(masked.width, (0..masked.data.len()).map(|i| (luma(masked, i) - luma(plain, i)).max(0.0)))
|
||||
}
|
||||
|
||||
/// The geometry changes a user makes after drawing a mask.
|
||||
pub(crate) fn geometries() -> Vec<(&'static str, Box<dyn Fn(&mut DevelopSettings)>)> {
|
||||
vec![
|
||||
("straighten", Box::new(|s: &mut DevelopSettings| s.crop.geometry = CropGeometry { rect: Rect::new(0.08, 0.1, 0.92, 0.9), angle: 7.0 })),
|
||||
(
|
||||
"straighten the other way",
|
||||
Box::new(|s: &mut DevelopSettings| s.crop.geometry = CropGeometry { rect: Rect::new(0.08, 0.1, 0.92, 0.9), angle: -9.0 }),
|
||||
),
|
||||
("crop", Box::new(|s: &mut DevelopSettings| s.crop.geometry.rect = Rect::new(0.1, 0.15, 0.7, 0.8))),
|
||||
("flip", Box::new(|s: &mut DevelopSettings| s.crop.flip_h = true)),
|
||||
("flip vertical", Box::new(|s: &mut DevelopSettings| s.crop.flip_v = true)),
|
||||
(
|
||||
"perspective",
|
||||
Box::new(|s: &mut DevelopSettings| {
|
||||
s.geometry.vertical = 25.0;
|
||||
s.geometry.rotate = 4.0;
|
||||
}),
|
||||
),
|
||||
("distortion", Box::new(|s: &mut DevelopSettings| s.optics.distortion = 40.0)),
|
||||
(
|
||||
// Rotate Right: the photo and its masks turn together (`photo.rotateRight`)
|
||||
"quarter turn",
|
||||
Box::new(|s: &mut DevelopSettings| {
|
||||
let turn = lightcraft_geom::Orientation::Normal.rotated(true);
|
||||
s.orientation = s.orientation.rotated(true);
|
||||
for m in &mut s.masks {
|
||||
crate::mask_space::turn(m, turn);
|
||||
}
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn check(name: &str, shape: MaskShape) {
|
||||
let src = Arc::new(marked(true));
|
||||
let req = RenderRequest::fit(W, H);
|
||||
let info = SourceInfo::default();
|
||||
for (what, change) in geometries() {
|
||||
let mut s = with_mask(shape.clone());
|
||||
// a view that rendered the photo before the change (its stage cache holds the mask)
|
||||
let cache = StageCache::default();
|
||||
render_cached(&src, &info, &s, &req, &cache);
|
||||
change(&mut s);
|
||||
let plain = DevelopSettings { masks: Vec::new(), ..s.clone() };
|
||||
let p = render(&src, &info, &plain, &req).image;
|
||||
let mark = mark_at(&plain);
|
||||
for (how, m) in [("fresh", render(&src, &info, &s, &req).image), ("cached", render_cached(&src, &info, &s, &req, &cache).image)] {
|
||||
let at = mask_at(&m, &p);
|
||||
let d = ((at.0 - mark.0).powi(2) + (at.1 - mark.1).powi(2)).sqrt();
|
||||
assert!(d < 2.0, "{name} after {what} ({how}): the mask's effect is at {at:?}, the mark at {mark:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_mask_stays_on_the_content_under_every_geometry_change() {
|
||||
check("radial", radial());
|
||||
}
|
||||
|
||||
fn brush() -> MaskShape {
|
||||
let l = W.max(H) as f64;
|
||||
MaskShape::Brush {
|
||||
strokes: vec![BrushStroke {
|
||||
points: vec![Point::new(MARK.0 - 0.005, MARK.1), Point::new(MARK.0 + 0.005, MARK.1)],
|
||||
size: 8.0 / l,
|
||||
..Default::default()
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_brushed_mask_stays_on_the_content_under_every_geometry_change() {
|
||||
check("brush", brush());
|
||||
}
|
||||
|
||||
/// A mask from before masks were on the photo keeps its place on the corrected frame: an old edit
|
||||
/// renders as it did.
|
||||
#[test]
|
||||
fn an_old_mask_stays_where_it_was_drawn_on_the_corrected_frame() {
|
||||
let src = Arc::new(marked(true));
|
||||
let req = RenderRequest::fit(W, H);
|
||||
let info = SourceInfo::default();
|
||||
let mut s = with_mask(radial());
|
||||
s.masks[0].on_photo = false;
|
||||
s.geometry.vertical = 25.0;
|
||||
s.geometry.rotate = 6.0;
|
||||
let unmasked = DevelopSettings { masks: Vec::new(), ..s.clone() };
|
||||
let plain = render(&src, &info, &unmasked, &req).image;
|
||||
let at = mask_at(&render(&src, &info, &s, &req).image, &plain);
|
||||
let frame = crate::geometry::Frame::new(W, H, &s, true);
|
||||
let (w, h) = frame.fit(W, H);
|
||||
let want = frame.norm_to_out(w, h).apply(Point::new(MARK.0, MARK.1));
|
||||
assert!((at.0 - want.x).hypot(at.1 - want.y) < 2.0, "{at:?} vs {want:?}");
|
||||
let mark = mark_at(&unmasked);
|
||||
assert!((at.0 - mark.0).hypot(at.1 - mark.1) > 3.0, "and not on the mark, which the warp moved: {at:?} vs {mark:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_mask_stays_on_the_content_under_every_geometry_change() {
|
||||
check("object", subject());
|
||||
}
|
||||
|
||||
/// A brush point the warp can't place lays no dab, rather than one at its unwarped place, which
|
||||
/// can be far from the stroke (review of #3550).
|
||||
#[test]
|
||||
fn a_brush_point_the_warp_cannot_place_lays_no_dab() {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.geometry.vertical = -100.0;
|
||||
s.geometry.horizontal = -100.0;
|
||||
s.optics.distortion = 100.0;
|
||||
s.geometry.scale = 70.0;
|
||||
let frame = crate::geometry::Frame::new(1000, 1500, &s, true).for_mask(true).into_owned();
|
||||
let (w, h) = frame.fit(1000, 1500);
|
||||
let warp = frame.mask_warp().cloned().expect("a warp");
|
||||
// a point on the photo the inverse can't reach (found by search, so the test doesn't depend on
|
||||
// the solver's exact limits)
|
||||
let lost = (0..200 * 200)
|
||||
.map(|i| Point::new((i % 200) as f64 / 199.0, (i / 200) as f64 / 199.0))
|
||||
.find(|p| warp.from_source(Point::new(p.x * frame.ow, p.y * frame.oh)).is_none());
|
||||
let lost = lost.expect("these settings leave points the inverse can't reach");
|
||||
let stroke = BrushStroke { points: vec![lost], size: 0.01, ..Default::default() };
|
||||
let dabs = crate::masks::brush_dabs_within(&stroke, &frame, w, h, 1000);
|
||||
assert!(dabs.dabs.is_empty(), "{lost:?} laid {:?}", dabs.dabs);
|
||||
}
|
||||
@@ -191,7 +191,11 @@ impl DiskCache {
|
||||
let used = total.get_or_insert_with(|| self.scan().iter().map(|f| f.1).sum());
|
||||
let old_len = std::fs::metadata(&p).map_or(0, |m| m.len());
|
||||
let tmp = p.with_extension(format!("tmp{:?}", std::thread::current().id()).replace(['(', ')'], ""));
|
||||
if std::fs::write(&tmp, &bytes).is_err() || std::fs::rename(&tmp, &p).is_err() {
|
||||
// a new temp file: an entry already under the name (a link planted in the cache would be
|
||||
// written through) is removed, never opened
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let written = std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp).and_then(|mut f| std::io::Write::write_all(&mut f, &bytes));
|
||||
if written.is_err() || std::fs::rename(&tmp, &p).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,12 +115,22 @@ impl Pixel for [f32; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize to `w × h` (separable; horizontal pass then vertical pass).
|
||||
/// Resize to `w × h` (separable; horizontal pass then vertical pass, or the other way round when
|
||||
/// that intermediate would be far larger: widening a tall, narrow strip kept every source row at
|
||||
/// the new width, gigabytes from a small source).
|
||||
pub fn resize<T: Pixel>(img: &Image<T>, w: usize, h: usize, filter: Filter) -> Image<T> {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
if img.width == w && img.height == h {
|
||||
return img.clone();
|
||||
}
|
||||
if w.saturating_mul(img.height) > img.width.saturating_mul(h).saturating_mul(4) {
|
||||
return horizontal(&vertical(img, h, filter), w, filter);
|
||||
}
|
||||
vertical(&horizontal(img, w, filter), h, filter)
|
||||
}
|
||||
|
||||
/// Each row resampled to `w` wide.
|
||||
fn horizontal<T: Pixel>(img: &Image<T>, w: usize, filter: Filter) -> Image<T> {
|
||||
let wx = weights(img.width, w, filter);
|
||||
let mut tmp = Image::<T>::new(w, img.height);
|
||||
par_rows(&mut tmp.data, w, |y, row| {
|
||||
@@ -134,12 +144,18 @@ pub fn resize<T: Pixel>(img: &Image<T>, w: usize, h: usize, filter: Filter) -> I
|
||||
*o = acc;
|
||||
}
|
||||
});
|
||||
tmp
|
||||
}
|
||||
|
||||
/// Each column resampled to `h` tall.
|
||||
fn vertical<T: Pixel>(img: &Image<T>, h: usize, filter: Filter) -> Image<T> {
|
||||
let w = img.width;
|
||||
let wy = weights(img.height, h, filter);
|
||||
let mut out = Image::<T>::new(w, h);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
let (lo, ws) = &wy[y];
|
||||
for (k, wt) in ws.iter().enumerate() {
|
||||
let src = tmp.row(lo + k);
|
||||
let src = img.row(lo + k);
|
||||
for (o, s) in row.iter_mut().zip(src) {
|
||||
*o = o.madd(*s, *wt);
|
||||
}
|
||||
@@ -178,6 +194,16 @@ pub fn half<T: Pixel>(img: &Image<T>) -> Image<T> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn widening_a_tall_strip_resamples_its_columns_first() {
|
||||
// 1 × 400 000 to 4000 × 8: horizontal-first kept 400 000 rows of 4000 (25 GB of RGBA f32)
|
||||
let mut strip = Image::<[f32; 4]>::new(1, 400_000);
|
||||
strip.data.iter_mut().for_each(|p| *p = [0.25, 0.5, 0.75, 1.0]);
|
||||
let out = resize(&strip, 4000, 8, Filter::Mitchell);
|
||||
assert_eq!((out.width, out.height), (4000, 8));
|
||||
assert!(out.data.iter().all(|p| (p[1] - 0.5).abs() < 1e-3), "a flat strip stays flat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_image_stays_constant() {
|
||||
let img = Image::<f32>::filled(37, 21, 0.42);
|
||||
|
||||
@@ -566,8 +566,45 @@ const MAX_BAD_PIXELS: usize = 1 << 22;
|
||||
/// Apply a list to an interleaved `w × h × cpp` buffer. `cfa` (when the data is a mosaic) is used by the
|
||||
/// bad-pixel opcodes. `scale` is the value of 1.0 in the buffer: 65535 for list 1 (raw 16-bit values), 1 for
|
||||
/// lists 2 and 3 (normalised values).
|
||||
/// Whole-image passes one opcode list may cost, all its ops together (a real DNG's list is a
|
||||
/// handful of gain maps and tables). Ops past it, in list order, are left out: a list repeating a
|
||||
/// full-image polynomial thousands of times was billions of evaluations on an ordinary raw.
|
||||
const MAX_LIST_PASSES: usize = 32;
|
||||
|
||||
/// Sample visits `op` costs on a `w × h × cpp` buffer (coefficient evaluations for a polynomial).
|
||||
fn op_cost(op: &Opcode, w: usize, h: usize, cpp: usize) -> usize {
|
||||
let visits = |a: &Area| {
|
||||
let rows = (a.bottom as usize).min(h).saturating_sub(a.top as usize).div_ceil(a.row_pitch.max(1) as usize);
|
||||
let cols = (a.right as usize).min(w).saturating_sub(a.left as usize).div_ceil(a.col_pitch.max(1) as usize);
|
||||
let planes = (a.plane as usize).saturating_add(a.planes.max(1) as usize).min(cpp).saturating_sub(a.plane as usize);
|
||||
rows.saturating_mul(cols).saturating_mul(planes)
|
||||
};
|
||||
match op {
|
||||
Opcode::MapPolynomial { area, coefficients } => visits(area).saturating_mul(coefficients.len().max(1)),
|
||||
Opcode::MapTable { area, .. }
|
||||
| Opcode::GainMap { area, .. }
|
||||
| Opcode::DeltaPerRow { area, .. }
|
||||
| Opcode::DeltaPerColumn { area, .. }
|
||||
| Opcode::ScalePerRow { area, .. }
|
||||
| Opcode::ScalePerColumn { area, .. } => visits(area),
|
||||
// `apply_list` does nothing for these, so they spend none of the budget
|
||||
Opcode::Unknown { .. }
|
||||
| Opcode::TrimBounds { .. }
|
||||
| Opcode::WarpFisheye { .. }
|
||||
| Opcode::WarpRectilinear { .. }
|
||||
| Opcode::WarpRectilinear2 { .. } => 0,
|
||||
_ => w.saturating_mul(h).saturating_mul(cpp),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_list(list: &[Opcode], buf: &mut [f32], w: usize, h: usize, cpp: usize, cfa: Option<&Cfa>, scale: f32) {
|
||||
let mut budget = w.saturating_mul(h).saturating_mul(cpp).max(1).saturating_mul(MAX_LIST_PASSES);
|
||||
for op in list {
|
||||
let cost = op_cost(op, w, h, cpp);
|
||||
if cost > budget {
|
||||
continue;
|
||||
}
|
||||
budget -= cost;
|
||||
match op {
|
||||
Opcode::MapTable { area, table } => {
|
||||
let n = table.len();
|
||||
|
||||
@@ -456,3 +456,30 @@ fn cropped_lens_corrections_correct_the_crop_like_the_whole() {
|
||||
assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none());
|
||||
assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none());
|
||||
}
|
||||
|
||||
/// Ten thousand full-image polynomials (a few hundred KB of opcode data): the list is held to
|
||||
/// `MAX_LIST_PASSES` passes of work, the first ones applied and the rest left out.
|
||||
#[test]
|
||||
fn a_list_repeating_full_image_work_stops_at_its_budget() {
|
||||
let (w, h) = (64usize, 48usize);
|
||||
let a = Area { top: 0, left: 0, bottom: h as u32, right: w as u32, plane: 0, planes: 1, row_pitch: 1, col_pitch: 1 };
|
||||
// v + 0.001 each, two coefficients: each costs two passes
|
||||
let list = vec![Opcode::MapPolynomial { area: a, coefficients: vec![0.001, 1.0] }; 10_000];
|
||||
let mut buf = vec![0.0f32; w * h];
|
||||
apply_list(&list, &mut buf, w, h, 1, None, 1.0);
|
||||
let applied = (buf[0] / 0.001).round() as usize;
|
||||
assert_eq!(applied, MAX_LIST_PASSES / 2, "{}", buf[0]);
|
||||
assert!(buf.iter().all(|v| *v == buf[0]));
|
||||
}
|
||||
|
||||
/// Opcodes `apply_list` does nothing for spend none of the budget: a later real one still runs.
|
||||
#[test]
|
||||
fn ops_that_apply_nothing_cost_no_budget() {
|
||||
let (w, h) = (8usize, 8usize);
|
||||
let a = Area { top: 0, left: 0, bottom: h as u32, right: w as u32, plane: 0, planes: 1, row_pitch: 1, col_pitch: 1 };
|
||||
let mut list = vec![Opcode::Unknown { id: 9999, flags: 0, params: Vec::new() }; 4 * MAX_LIST_PASSES];
|
||||
list.push(Opcode::MapPolynomial { area: a, coefficients: vec![0.5, 1.0] });
|
||||
let mut buf = vec![0.0f32; w * h];
|
||||
apply_list(&list, &mut buf, w, h, 1, None, 1.0);
|
||||
assert_eq!(buf[0], 0.5);
|
||||
}
|
||||
|
||||
+51
-14
@@ -121,7 +121,8 @@ impl Ranges {
|
||||
|
||||
/// The parts of `[start, end)` not covered.
|
||||
pub fn gaps(&self, start: u64, end: u64) -> Ranges {
|
||||
let mut out = Ranges::new();
|
||||
// the gaps come in order, apart from each other: pushed, not merged in one by one
|
||||
let mut out = Vec::new();
|
||||
let mut at = start;
|
||||
for &(a, b) in &self.0 {
|
||||
if b <= at {
|
||||
@@ -131,24 +132,20 @@ impl Ranges {
|
||||
break;
|
||||
}
|
||||
if a > at {
|
||||
out.insert(at, a);
|
||||
out.push((at, a));
|
||||
}
|
||||
at = at.max(b);
|
||||
}
|
||||
if at < end {
|
||||
out.insert(at, end);
|
||||
out.push((at, end));
|
||||
}
|
||||
out
|
||||
Ranges(out)
|
||||
}
|
||||
|
||||
/// These ranges without the ones in `other`.
|
||||
pub fn minus(&self, other: &Ranges) -> Ranges {
|
||||
let mut out = Ranges::new();
|
||||
for &(a, b) in &self.0 {
|
||||
for &(x, y) in other.gaps(a, b).spans() {
|
||||
out.insert(x, y);
|
||||
}
|
||||
}
|
||||
out.extend(self.0.iter().flat_map(|&(a, b)| other.gaps(a, b).0));
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -201,12 +198,17 @@ fn plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
|
||||
struct Want<'a> {
|
||||
len: u64,
|
||||
loaded: &'a Ranges,
|
||||
need: Ranges,
|
||||
/// What is missing, as found; merged once into the plan (merging each find into the ranges so
|
||||
/// far was a copy of them per find: quadratic over a file of many out-of-line values).
|
||||
need: Vec<(u64, u64)>,
|
||||
/// `need` is merged when it reaches this long, so repeated finds of one gap stay bounded by
|
||||
/// the distinct gaps (merging when it doubles keeps the cost linear).
|
||||
compact_at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Want<'a> {
|
||||
fn new(bytes: &[u8], loaded: &'a Ranges) -> Self {
|
||||
Self { len: bytes.len() as u64, loaded, need: Ranges::new() }
|
||||
Self { len: bytes.len() as u64, loaded, need: Vec::new(), compact_at: 64 }
|
||||
}
|
||||
|
||||
/// Whether `[start, end)` (clipped to the file) is loaded; asks for what isn't.
|
||||
@@ -215,15 +217,21 @@ impl<'a> Want<'a> {
|
||||
if self.loaded.contains(start, end) {
|
||||
return true;
|
||||
}
|
||||
for &(a, b) in self.loaded.gaps(start, end).spans() {
|
||||
self.need.insert(a, b);
|
||||
self.need.extend(self.loaded.gaps(start, end).0);
|
||||
if self.need.len() >= self.compact_at {
|
||||
let mut merged = Ranges::new();
|
||||
merged.extend(std::mem::take(&mut self.need));
|
||||
self.need = merged.spans().to_vec();
|
||||
self.compact_at = (self.need.len() * 2).max(64);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `Need` when something is missing.
|
||||
fn into_plan(self) -> Option<HeaderPlan> {
|
||||
(!self.need.is_empty()).then_some(HeaderPlan::Need(self.need))
|
||||
let mut need = Ranges::new();
|
||||
need.extend(self.need);
|
||||
(!need.is_empty()).then_some(HeaderPlan::Need(need))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +567,35 @@ mod tests {
|
||||
assert_eq!(r.total(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plan_of_many_missing_values_is_gathered_in_one_merge() {
|
||||
// 200 000 loaded spans with a byte missing between each: every gap wanted once
|
||||
let mut loaded = Ranges::new();
|
||||
loaded.extend((0..20_000u64).map(|i| (i * 4, i * 4 + 3)));
|
||||
let bytes = vec![0u8; 80_000];
|
||||
let mut w = Want::new(&bytes, &loaded);
|
||||
for i in 0..20_000u64 {
|
||||
assert!(!w.want(i * 4 + 2, i * 4 + 4));
|
||||
}
|
||||
let Some(HeaderPlan::Need(need)) = w.into_plan() else { panic!("a plan") };
|
||||
assert_eq!(need.spans().len(), 20_000);
|
||||
assert_eq!(need.total(), 20_000);
|
||||
assert_eq!(loaded.gaps(0, 12).spans(), &[(3, 4), (7, 8), (11, 12)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_wants_of_one_gap_keep_the_buffer_bounded() {
|
||||
let loaded = Ranges::new();
|
||||
let bytes = vec![0u8; 1000];
|
||||
let mut w = Want::new(&bytes, &loaded);
|
||||
for _ in 0..100_000 {
|
||||
assert!(!w.want(10, 20));
|
||||
assert!(w.need.len() <= 64);
|
||||
}
|
||||
let Some(HeaderPlan::Need(need)) = w.into_plan() else { panic!("a plan") };
|
||||
assert_eq!(need.spans(), &[(10, 20)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics_and_ends() {
|
||||
let mut loaded = Ranges::new();
|
||||
|
||||
@@ -63,8 +63,20 @@ pub fn check_image(data: &[u8], info: &ImageInfo) -> Result<usize> {
|
||||
return Err(RawError::Limit("image data chunks far larger than the image"));
|
||||
}
|
||||
// Plausibility bound before allocating: no supported coding stores more than ~2000 samples per byte
|
||||
// (Deflate of constant data is the extreme), so tiny files cannot trigger huge allocations.
|
||||
let available: u64 = chunks.iter().map(|c| chunk_bytes(data, c).map_or(0, |s| s.len() as u64)).sum();
|
||||
// (Deflate of constant data is the extreme), so tiny files cannot trigger huge allocations. The
|
||||
// bytes are counted once however many chunks point at them: a grid of tiles sharing one small
|
||||
// payload holds that payload's samples, not the grid's.
|
||||
let mut spans: Vec<(u64, u64)> =
|
||||
chunks.iter().filter_map(|c| Some((c.offset, c.offset.checked_add(chunk_bytes(data, c)?.len() as u64)?))).collect();
|
||||
spans.sort_unstable();
|
||||
let (mut available, mut end) = (0u64, 0u64);
|
||||
for (a, b) in spans {
|
||||
let a = a.max(end);
|
||||
if b > a {
|
||||
available = available.saturating_add(b - a);
|
||||
end = b;
|
||||
}
|
||||
}
|
||||
if available.saturating_mul(2048) < total as u64 {
|
||||
return Err(RawError::Corrupt(format!("{available} bytes of image data cannot hold {total} samples")));
|
||||
}
|
||||
@@ -429,6 +441,30 @@ mod lossy_tests {
|
||||
use super::*;
|
||||
use lightcraft_tiff::image::Layout;
|
||||
|
||||
/// 65 536 tiles all pointing at the same 256 bytes: the image's samples can't come from them,
|
||||
/// and nothing of its size is allocated to find that out.
|
||||
#[test]
|
||||
fn tiles_sharing_one_payload_count_its_bytes_once() {
|
||||
let file = vec![0u8; 8 + 256];
|
||||
let info = ImageInfo {
|
||||
width: 4096,
|
||||
height: 4096,
|
||||
bits_per_sample: vec![8],
|
||||
samples_per_pixel: 1,
|
||||
compression: comp::NONE,
|
||||
photometric: 34892,
|
||||
planar: 1,
|
||||
predictor: 1,
|
||||
sample_format: 1,
|
||||
new_subfile_type: 0,
|
||||
layout: Layout::Tiles { tile_width: 16, tile_height: 16 },
|
||||
offsets: vec![8; 65_536],
|
||||
byte_counts: vec![256; 65_536],
|
||||
};
|
||||
let err = check_image(&file, &info).unwrap_err().to_string();
|
||||
assert!(err.contains("cannot hold"), "{err}");
|
||||
}
|
||||
|
||||
/// A lossy-DNG-style image: two 16×8 tiles, each a baseline JPEG, the right one cut short
|
||||
/// by the image edge.
|
||||
#[test]
|
||||
|
||||
Vendored
+2
-1
@@ -790,7 +790,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let rm6: Vec<crate::Opcode> = if linear_rgb || !super::arw_lens::is_3_2(crop) {
|
||||
Vec::new()
|
||||
} else {
|
||||
super::arw_lens_rm6::lens_opcodes(raw, &model, Rect::new(0, 0, w, h), reference)
|
||||
let lens = metadata.lens_model.as_deref();
|
||||
super::arw_lens_rm6::lens_opcodes(raw, &model, lens, metadata.focal_length, Rect::new(0, 0, w, h), reference)
|
||||
};
|
||||
if !rm6.is_empty()
|
||||
&& let Some(record) = sony_lens.as_mut()
|
||||
|
||||
Vendored
+48
-5
@@ -1,6 +1,6 @@
|
||||
//! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of
|
||||
//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and validated
|
||||
//! ILCE-7RM4A and ILCE-7CR distortion tables into DNG warps for the shared optics pipeline.
|
||||
//! ILCE-7RM4A, ILCE-7M3 and ILCE-7CR distortion tables into DNG warps for the shared optics pipeline.
|
||||
//!
|
||||
//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and
|
||||
//! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags
|
||||
@@ -34,9 +34,9 @@ const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x798
|
||||
/// TIFF field types.
|
||||
const SHORT: u16 = 3;
|
||||
const SSHORT: u16 = 8;
|
||||
/// Models using the validated A7R IVA conversion. The A7CR uses a separate conversion.
|
||||
/// Models using the validated A7R IVA conversion (ILCE-7RM4A, ILCE-7M3). The A7CR uses a separate conversion.
|
||||
/// ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases.
|
||||
const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A"];
|
||||
const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A", "ILCE-7M3"];
|
||||
/// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one.
|
||||
const MAX_ENTRIES: usize = 1 << 16;
|
||||
const SAMPLES: usize = 1024;
|
||||
@@ -1397,6 +1397,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ILCE-7M3 raw-IFD tables (FE 16-35mm F4 ZA OSS at 16 and 35 mm, FE 28-70mm F3.5-5.6 OSS at 70 mm) and the
|
||||
// geometry of the camera's own JPEG of the same exposure (Lens Comp. Distortion: Auto): checkerboard corners of
|
||||
// that JPEG matched with the uncorrected raw and fitted with the warp's polynomial over the 6000×4000 frame (rms
|
||||
// 0.11–0.25 px). See docs/sony-lens-corrections.md.
|
||||
const A7M3: [([i16; 16], [f64; 4]); 3] = [
|
||||
(
|
||||
[32, 2, -38, -83, -138, -199, -270, -336, -402, -462, -519, -568, -611, -643, -670, -690],
|
||||
[1.0247000859, -0.1092847508, 0.1061422243, -0.0397989678],
|
||||
),
|
||||
([2, 2, 4, 9, 18, 29, 45, 65, 92, 124, 164, 210, 264, 329, 403, 489], [0.9708150714, 0.0157256288, 0.0175613359, -0.004107544]),
|
||||
([4, 7, 18, 40, 68, 108, 155, 212, 280, 359, 453, 557, 679, 819, 978, 1161], [0.9342002611, 0.0560486347, 0.0076184603, 0.0021497933]),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn ilce_7m3_matches_the_cameras_own_jpeg_geometry() {
|
||||
let frame = Rect::new(0, 0, 6000, 4000);
|
||||
let evaluate = |k: &[f64], r: f64| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3])));
|
||||
for (values, reference) in A7M3 {
|
||||
let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7M3", &values, frame, frame).unwrap() else { panic!("warp") };
|
||||
assert_eq!(center, [0.5, 0.5]);
|
||||
// The camera fills its frame with its own zoom; compare the shape over the measured radii (to 0.97).
|
||||
let radii: Vec<f64> = (1..=97).map(|i| i as f64 / 100.0).collect();
|
||||
let (ours, camera): (Vec<f64>, Vec<f64>) = radii.iter().map(|&r| (evaluate(&planes[0], r), evaluate(&reference, r))).unzip();
|
||||
let worst = |ours: &[f64]| {
|
||||
let zoom = ours.iter().zip(&camera).map(|(a, b)| a * b).sum::<f64>() / ours.iter().map(|a| a * a).sum::<f64>();
|
||||
ours.iter().zip(&camera).map(|(a, b)| (a * zoom - b).abs()).fold(0.0, f64::max)
|
||||
};
|
||||
// 0.0008 of the half diagonal is 2.9 px on the 6000 px frame (measured: 0.4–2.4 px); uncorrected is 10×+ off.
|
||||
let (corrected, uncorrected) = (worst(&ours), worst(&radii));
|
||||
assert!(corrected < 0.0008 && uncorrected > 10.0 * corrected, "{values:?}: {corrected} vs {uncorrected}");
|
||||
}
|
||||
// Sony's crop and the default crop agree on this body (12, 12, 6000×4000 of 6048×4024), so the warp sits on
|
||||
// the image centre.
|
||||
let Opcode::WarpRectilinear { center, .. } =
|
||||
distortion("ILCE-7M3", &A7M3[0].0, Rect::new(0, 0, 6048, 4024), Rect::new(12, 12, 6000, 4000)).unwrap()
|
||||
else {
|
||||
panic!("warp")
|
||||
};
|
||||
assert!((center[0] * 6047.0 - 3011.5).abs() < 1e-9 && (center[1] * 4023.0 - 2011.5).abs() < 1e-9, "{center:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn barrel_framing_and_offset_crop_keep_the_same_geometry() {
|
||||
let crop = Rect::new(32, 20, 9504, 6336);
|
||||
@@ -1427,14 +1468,16 @@ mod tests {
|
||||
#[test]
|
||||
fn refuses_unvalidated_models_even_with_a_valid_table() {
|
||||
assert!(distortion("ILCE-7RM4A", &TELE, AREA, AREA).is_some());
|
||||
for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7CR"] {
|
||||
assert!(distortion("ILCE-7M3", &TELE, AREA, AREA).is_some());
|
||||
for model in ["", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7CR", "ILCE-7M3 ", "ILCE-7SM3"]
|
||||
{
|
||||
assert!(distortion(model, &TELE, AREA, AREA).is_none(), "{model}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_and_full_decode_preserve_correction_through_dng() {
|
||||
for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] {
|
||||
for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 1), ("ILCE-7M4", 0), ("", 0)] {
|
||||
for (height, expected) in [(20, expected), (16, 0)] {
|
||||
for order in [ByteOrder::Little, ByteOrder::Big] {
|
||||
let mut raw = IfdBuilder::new();
|
||||
|
||||
+78
-18
@@ -62,6 +62,32 @@ const FULL_FRAME_CROP: &[(&str, (f64, f64))] = &[("ILCE-7RM6", (9984.0, 6656.0))
|
||||
/// The verified APS-C bodies (Exif `Model`): their knots span the file's own default crop, the last knot at its corner.
|
||||
/// Measured on the ILCE-6700 (docs/sony-ilce7rm6-lens-corrections.md → ILCE-6700).
|
||||
const APSC_BODIES: &[&str] = &["ILCE-6700"];
|
||||
|
||||
/// A lens whose tables follow its body's convention with another knot spacing and unit, at the focal lengths up to
|
||||
/// `max_focal_mm`; measured like the body, against the camera JPEG.
|
||||
struct LensException {
|
||||
/// Exif `Model` of the body.
|
||||
body: &'static str,
|
||||
/// Exif `LensModel`, as the body records it.
|
||||
lens: &'static str,
|
||||
max_focal_mm: f64,
|
||||
knot_spacing: f64,
|
||||
unit: f64,
|
||||
}
|
||||
|
||||
/// The measured lens exceptions (docs/sony-ilce7rm6-lens-corrections.md → ILCE-6700).
|
||||
const LENS_EXCEPTIONS: &[LensException] = &[
|
||||
// Sigma 10-18 mm F2.8 DC DN at its wide end: 97 photos at 10 mm and 2 at 10.4-10.5 mm. The body's rule leaves
|
||||
// 1.36-1.38 px there; from 11.7 mm on the body's rule fits better. `lens` is the only LensModel in 336 files of
|
||||
// this lens (native E-mount, no adapter); the 116 measured copies carry body firmware "ILCE-6700 v2.00"
|
||||
LensException { body: "ILCE-6700", lens: "10-18mm F2.8 DC DN | Contemporary 023", max_focal_mm: 10.5, knot_spacing: 416.0, unit: 14250.0 },
|
||||
];
|
||||
|
||||
/// The exception for the body `model` with the lens `lens` at `focal_mm`, if one is measured.
|
||||
fn lens_exception(model: &str, lens: Option<&str>, focal_mm: Option<f64>) -> Option<&'static LensException> {
|
||||
let (lens, focal) = (lens?.trim(), focal_mm.filter(|f| f.is_finite() && *f > 0.0)?);
|
||||
LENS_EXCEPTIONS.iter().find(|e| e.body == model.trim() && e.lens == lens && focal <= e.max_focal_mm)
|
||||
}
|
||||
/// The distortion table's value of 1 (relative radius); a global least-squares fit over the 34 photos gives 15000.7.
|
||||
const DISTORTION_UNIT: f64 = 15000.0;
|
||||
/// The CA table's value of 1 (relative to the green source radius). Provisional: the median best unit of the 20
|
||||
@@ -179,18 +205,19 @@ fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option<f64> {
|
||||
/// worse than 0.25 px at a 1440 px long edge at one of the fit samples within the crop (a padded active area, as in
|
||||
/// lossless files, reaches radii that are never shown).
|
||||
pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect, lateral_ca: bool) -> Option<Opcode> {
|
||||
gated_warp_opcode(t, knot_spacing, active, crop, lateral_ca, FIT_ERROR_PX_AT_1440)
|
||||
gated_warp_opcode(t, knot_spacing, DISTORTION_UNIT, active, crop, lateral_ca, FIT_ERROR_PX_AT_1440)
|
||||
}
|
||||
|
||||
/// [`warp_opcode`] with the fit gate `fit_error_px` (px at a 1440 px long edge) instead of 0.25 px.
|
||||
fn gated_warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect, lateral_ca: bool, fit_error_px: f64) -> Option<Opcode> {
|
||||
/// [`warp_opcode`] with the distortion unit `unit` instead of 15000 and the fit gate `fit_error_px` (px at a 1440 px
|
||||
/// long edge) instead of 0.25 px.
|
||||
fn gated_warp_opcode(t: &LensTables, knot_spacing: f64, unit: f64, active: Rect, crop: Rect, lateral_ca: bool, fit_error_px: f64) -> Option<Opcode> {
|
||||
let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?;
|
||||
let (aw, ah) = (active.width as f64, active.height as f64);
|
||||
if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let (cw, ch) = (crop.width as f64, crop.height as f64);
|
||||
let source = |r: f64| r * (1.0 + interpolate(v, knot_spacing, r) / DISTORTION_UNIT);
|
||||
let source = |r: f64| r * (1.0 + interpolate(v, knot_spacing, r) / unit);
|
||||
let z = fill_zoom(&source, cw / 2.0, ch / 2.0)?;
|
||||
// DNG's normalisation: pixel-index units, radius 1 = the farthest active-area corner
|
||||
let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0);
|
||||
@@ -226,12 +253,16 @@ fn gated_warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect
|
||||
/// R and B by 0.84 × the table. Over the 43 verified photos with enough corner detail, the render's corner R/G and B/G
|
||||
/// CA went from rms 0.032 / max 0.125 px (off) to 0.052 / 0.219 px (on) at a 1440 px long edge; only the 16–35 mm
|
||||
/// improved. Follow-up: warp the CFA colour planes before demosaic, then turn the planes on here.
|
||||
pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec<Opcode> {
|
||||
///
|
||||
/// `lens` (Exif `LensModel`) and `focal_mm` (Exif `FocalLength`) select a measured lens exception's spacing and unit.
|
||||
pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, lens: Option<&str>, focal_mm: Option<f64>, active: Rect, crop: Rect) -> Vec<Opcode> {
|
||||
let tables = read_tables(raw);
|
||||
let knots = tables.distortion.as_ref().map_or(0, Vec::len);
|
||||
let Some(spacing) = knot_spacing(model, knots, crop) else { return Vec::new() };
|
||||
let op = if APSC_BODIES.contains(&model.trim()) {
|
||||
gated_warp_opcode(&tables, spacing, active, crop, false, APSC_FIT_ERROR_PX_AT_1440)
|
||||
let op = if let Some(e) = lens_exception(model, lens, focal_mm) {
|
||||
gated_warp_opcode(&tables, e.knot_spacing, e.unit, active, crop, false, APSC_FIT_ERROR_PX_AT_1440)
|
||||
} else if APSC_BODIES.contains(&model.trim()) {
|
||||
gated_warp_opcode(&tables, spacing, DISTORTION_UNIT, active, crop, false, APSC_FIT_ERROR_PX_AT_1440)
|
||||
} else {
|
||||
warp_opcode(&tables, spacing, active, crop, false)
|
||||
};
|
||||
@@ -372,7 +403,7 @@ mod tests {
|
||||
let t = read_tables(&ifd);
|
||||
assert!(t.distortion.is_none(), "{what}");
|
||||
assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}");
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}");
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7RM6", None, None, FF_ACTIVE, FF_CROP).is_empty(), "{what}");
|
||||
}
|
||||
// not an integer array, or absent
|
||||
for value in [Value::Ascii("16 0 2 7".into()), Value::Double(vec![2.0, 0.0, 1.0])] {
|
||||
@@ -406,7 +437,7 @@ mod tests {
|
||||
let mut table = vec![v; 17];
|
||||
table[0] = 16;
|
||||
let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SLong8(table))]);
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{v}");
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7RM6", None, None, FF_ACTIVE, FF_CROP).is_empty(), "{v}");
|
||||
}
|
||||
// degenerate geometry
|
||||
let ff = tables_with_distortion(&values(&FF_DISTORTION));
|
||||
@@ -455,7 +486,7 @@ mod tests {
|
||||
let t = read_tables(&ifd);
|
||||
assert!(t.ca.is_some() && t.distortion.is_none());
|
||||
assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true).is_none());
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty());
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7RM6", None, None, FF_ACTIVE, FF_CROP).is_empty());
|
||||
// an all-zero distortion table counts as absent
|
||||
let zero = LensTables { ca: t.ca, ..tables_with_distortion(&[0.0; 16]) };
|
||||
assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP, true).is_none());
|
||||
@@ -509,7 +540,7 @@ mod tests {
|
||||
let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [1, 2, 1]);
|
||||
let with_ca = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true);
|
||||
assert!(matches!(with_ca, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{with_ca:?}");
|
||||
let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP);
|
||||
let ops = lens_opcodes(&ifd, "ILCE-7RM6", None, None, FF_ACTIVE, FF_CROP);
|
||||
let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() else { panic!("{ops:?}") };
|
||||
assert_eq!(planes.len(), 1);
|
||||
let green = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false);
|
||||
@@ -578,12 +609,12 @@ mod tests {
|
||||
let square = Rect { width: A6700_CROP.height, ..A6700_CROP };
|
||||
assert_eq!(knot_spacing("ILCE-6700", 11, square), None);
|
||||
let ifd = sony_lens_ifd(&A6700_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [1, 2, 1]);
|
||||
let ops = lens_opcodes(&ifd, "ILCE-6700", A6700_CROP, A6700_CROP);
|
||||
let gated = gated_warp_opcode(&read_tables(&ifd), s, A6700_CROP, A6700_CROP, false, APSC_FIT_ERROR_PX_AT_1440);
|
||||
let ops = lens_opcodes(&ifd, "ILCE-6700", None, None, A6700_CROP, A6700_CROP);
|
||||
let gated = gated_warp_opcode(&read_tables(&ifd), s, DISTORTION_UNIT, A6700_CROP, A6700_CROP, false, APSC_FIT_ERROR_PX_AT_1440);
|
||||
assert_eq!(ops, gated.into_iter().collect::<Vec<_>>());
|
||||
assert_eq!(ops.len(), 1);
|
||||
// other APS-C bodies stay unverified
|
||||
assert!(lens_opcodes(&ifd, "ILCE-6600", A6700_CROP, A6700_CROP).is_empty());
|
||||
assert!(lens_opcodes(&ifd, "ILCE-6600", None, None, A6700_CROP, A6700_CROP).is_empty());
|
||||
}
|
||||
|
||||
/// The APS-C fit gate: a table the polynomial fits within 0.44 px is applied on the ILCE-6700, while the
|
||||
@@ -594,17 +625,46 @@ mod tests {
|
||||
let t = read_tables(&ifd);
|
||||
let s = knot_spacing("ILCE-6700", 11, A6700_DECODED_CROP).unwrap();
|
||||
assert_eq!(warp_opcode(&t, s, A6700_ACTIVE, A6700_DECODED_CROP, false), None);
|
||||
let ops = lens_opcodes(&ifd, "ILCE-6700", A6700_ACTIVE, A6700_DECODED_CROP);
|
||||
let ops = lens_opcodes(&ifd, "ILCE-6700", None, None, A6700_ACTIVE, A6700_DECODED_CROP);
|
||||
assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{ops:?}");
|
||||
}
|
||||
|
||||
const SIGMA_10_18: &str = "10-18mm F2.8 DC DN | Contemporary 023";
|
||||
|
||||
/// The Sigma 10-18 mm exception: its spacing and unit at 10-10.5 mm, the body's rule from 11.7 mm (and without a
|
||||
/// lens or focal length, on another body or another lens).
|
||||
#[test]
|
||||
fn lens_exception_applies_only_to_its_lens_body_and_focal_range() {
|
||||
let ifd = sony_lens_ifd(&A6700_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [1, 2, 1]);
|
||||
let t = read_tables(&ifd);
|
||||
let geometry = (A6700_ACTIVE, A6700_DECODED_CROP);
|
||||
let exception = gated_warp_opcode(&t, 416.0, 14250.0, geometry.0, geometry.1, false, APSC_FIT_ERROR_PX_AT_1440);
|
||||
let body = lens_opcodes(&ifd, "ILCE-6700", None, None, geometry.0, geometry.1);
|
||||
assert!(exception.is_some() && body.len() == 1);
|
||||
assert_ne!(body.first(), exception.as_ref());
|
||||
for focal in [10.0, 10.5] {
|
||||
let ops = lens_opcodes(&ifd, "ILCE-6700", Some(SIGMA_10_18), Some(focal), geometry.0, geometry.1);
|
||||
assert_eq!(ops.first(), exception.as_ref(), "{focal} mm");
|
||||
}
|
||||
for (lens, focal) in [(Some(SIGMA_10_18), Some(11.7)), (Some(SIGMA_10_18), None), (None, Some(10.0)), (Some("E 10-20mm F4 PZ G"), Some(10.0))]
|
||||
{
|
||||
let ops = lens_opcodes(&ifd, "ILCE-6700", lens, focal, geometry.0, geometry.1);
|
||||
assert_eq!(ops, body, "{lens:?} {focal:?}");
|
||||
}
|
||||
// hostile focal lengths fall back to the body's rule
|
||||
for focal in [f64::NAN, f64::INFINITY, -10.0, 0.0] {
|
||||
assert_eq!(lens_opcodes(&ifd, "ILCE-6700", Some(SIGMA_10_18), Some(focal), geometry.0, geometry.1), body);
|
||||
}
|
||||
assert_eq!(lens_exception("ILCE-6600", Some(SIGMA_10_18), Some(10.0)).map(|e| e.unit), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_model_gets_no_opcodes() {
|
||||
let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1]);
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7M4", FF_ACTIVE, FF_CROP).is_empty());
|
||||
assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty());
|
||||
assert!(lens_opcodes(&ifd, "ILCE-7M4", None, None, FF_ACTIVE, FF_CROP).is_empty());
|
||||
assert!(lens_opcodes(&ifd, "", None, None, FF_ACTIVE, FF_CROP).is_empty());
|
||||
// the camera's in-camera flag (0 = off here) doesn't matter: the table is applied
|
||||
let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP);
|
||||
let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", None, None, FF_ACTIVE, FF_CROP);
|
||||
assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, false).into_iter().collect::<Vec<_>>());
|
||||
assert_eq!(ops.len(), 1);
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -230,6 +230,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
(RawData::U16(d), 12)
|
||||
} else if total * 8 >= (n as u64) * 12 && total * 8 < (n as u64) * 13 && chunks.len() == 1 {
|
||||
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("ORF strip outside file".into()))?;
|
||||
// the strip as it is in the file (clipped at its end), not as its tag states it: one too short
|
||||
// for the samples is a truncated file, refused before they are allocated
|
||||
if (src.len() as u64).saturating_mul(8) < (n as u64).saturating_mul(12) {
|
||||
return Err(RawError::Corrupt("ORF strip shorter than its samples".into()));
|
||||
}
|
||||
// the depth is fixed here, so a header-only probe needs the samples only when the file doesn't state its
|
||||
// colour-filter layout
|
||||
let d = if mode == Mode::Full || stated.is_none() {
|
||||
|
||||
@@ -1070,8 +1070,8 @@ fn sony_embedded_distortion_is_limited_to_validated_models() {
|
||||
for (name, model, expected) in [
|
||||
("arw-sony-a7rm4a-compressed.arw", "ILCE-7RM4A", 1),
|
||||
("arw-sony-a9m2-compressed.arw", "ILCE-9M2", 0),
|
||||
("arw-sony-a7m3-compressed.arw", "ILCE-7M3", 0),
|
||||
("arw-sony-a7m3-uncompressed.arw", "ILCE-7M3", 0),
|
||||
("arw-sony-a7m3-compressed.arw", "ILCE-7M3", 1),
|
||||
("arw-sony-a7m3-uncompressed.arw", "ILCE-7M3", 1),
|
||||
("arw-sony-a7m4-14bit.arw", "ILCE-7M4", 0),
|
||||
("arw-sony-a7m4-lossless-l.arw", "ILCE-7M4", 0),
|
||||
("arw-sony-a7m4-lossless-m.arw", "ILCE-7M4", 0),
|
||||
@@ -1228,7 +1228,7 @@ fn a_sony_fixture_that_differs_from_its_pin_fails() {
|
||||
|
||||
/// Sony's lens-correction records (`lightcraft_raw::lens_metadata`, `crates/raw/src/vendor/arw_lens.rs`) on the public
|
||||
/// samples: shading, CA and distortion decoded from the raw IFD with their settings, equal to their `SR2SubIFD` copies,
|
||||
/// the same from header and full decode, and nothing applied beyond the validated ILCE-7RM4A distortion. Expected
|
||||
/// the same from header and full decode, and nothing applied beyond the validated ILCE-7RM4A and ILCE-7M3 distortion. Expected
|
||||
/// tables and settings: what ExifTool, run as a black-box tool, prints for these files. Every file is checked against
|
||||
/// its pinned SHA-256 before it is decoded; absent files are skipped.
|
||||
#[test]
|
||||
@@ -1352,13 +1352,13 @@ fn sony_lens_records_of_the_public_samples() {
|
||||
eprintln!("{name}: shading, CA and distortion decoded; distortion {status:?}");
|
||||
checked += 1;
|
||||
}
|
||||
// every Sony corpus file: only the ILCE-7RM4A's distortion is ever applied, and an applied status is an opcode
|
||||
// every Sony corpus file: only the ILCE-7RM4A's and ILCE-7M3's distortion is ever applied, and an applied status is an opcode
|
||||
for (name, _) in SONY_SAMPLES {
|
||||
let Some(bytes) = pinned_sony(&root, name) else { continue };
|
||||
let info = probe_info(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let Some(s) = &info.lens_metadata.sony else { continue };
|
||||
let applied = s.distortion.status == SonyLensStatus::Applied;
|
||||
assert_eq!(applied, info.metadata.model.as_deref() == Some("ILCE-7RM4A"), "{name}");
|
||||
assert_eq!(applied, matches!(info.metadata.model.as_deref(), Some("ILCE-7RM4A" | "ILCE-7M3")), "{name}");
|
||||
assert_eq!(info.opcodes.list3.len(), usize::from(applied), "{name}");
|
||||
assert!(![s.shading.status, s.chromatic_aberration.status].contains(&SonyLensStatus::Applied), "{name}");
|
||||
checked += 1;
|
||||
|
||||
@@ -34,6 +34,31 @@ pub fn preprocess(rgb: &[u8], w: usize, h: usize, side: usize) -> Result<Vec<f32
|
||||
if w == 0 || h == 0 || rgb.len() < n {
|
||||
return Err(Error::Model(format!("image buffer of {} bytes for {w} × {h}", rgb.len())));
|
||||
}
|
||||
// The horizontal pass keeps every source row (h × side samples): for a strip far taller than
|
||||
// it is wide that is many times the image itself, so such an image is resampled transposed
|
||||
// (its rows become columns) and the result transposed back.
|
||||
if h > w.saturating_mul(4) {
|
||||
let mut t = vec![0u8; n];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if let (Some(d), Some(s)) = (t.get_mut((x * h + y) * 3..(x * h + y) * 3 + 3), rgb.get((y * w + x) * 3..(y * w + x) * 3 + 3)) {
|
||||
d.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
let o = preprocess(&t, h, w, side)?;
|
||||
let mut out = vec![0f32; o.len()];
|
||||
for c in 0..3 {
|
||||
for oy in 0..side {
|
||||
for ox in 0..side {
|
||||
if let (Some(d), Some(v)) = (out.get_mut((c * side + oy) * side + ox), o.get((c * side + ox) * side + oy)) {
|
||||
*d = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let (wx, wy) = (weights(w, side), weights(h, side));
|
||||
// horizontal pass: h rows × side columns × 3
|
||||
let mut tmp = vec![0f32; h * side * 3];
|
||||
@@ -117,6 +142,19 @@ mod tests {
|
||||
#[test]
|
||||
fn short_buffers_are_errors() {
|
||||
assert!(preprocess(&[0; 5], 2, 2, 4).is_err());
|
||||
// a strip 1 pixel wide and 200 000 tall: resampled transposed, so the intermediate is
|
||||
// side × 1 rather than 200 000 × side; it equals the resample of its transposed twin
|
||||
let h = 200_000usize;
|
||||
let strip: Vec<u8> = (0..h * 3).map(|i| (i / 3 % 251) as u8).collect();
|
||||
let a = preprocess(&strip, 1, h, 8).unwrap();
|
||||
let b = preprocess(&strip, h, 1, 8).unwrap();
|
||||
for c in 0..3 {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
assert_eq!(a[(c * 8 + y) * 8 + x], b[(c * 8 + x) * 8 + y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(preprocess(&[], 0, 0, 4).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -313,6 +313,9 @@
|
||||
"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})",
|
||||
"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}",
|
||||
"Photo: {name}": "Fotografie: {name}",
|
||||
"Panel layout could not be restored: {error}": "Rozložení panelů se nepodařilo obnovit: {error}",
|
||||
"Panel move was not applied: {error}": "Přesun panelu nebyl použit: {error}",
|
||||
|
||||
@@ -2456,6 +2456,25 @@
|
||||
"The photos are imported once it ends": "Fotky se naimportují potom",
|
||||
"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",
|
||||
"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í.",
|
||||
"Lens Profiles": "Profily objektivů",
|
||||
"Download lens profiles?": "Stáhnout profily objektivů?",
|
||||
"LightCraft can correct distortion and vignetting for many lenses using the open lens database (lensfun, about {size} MB).": "LightCraft umí opravit zkreslení a vinětaci mnoha objektivů pomocí otevřené databáze objektivů (lensfun, asi {size} MB).",
|
||||
"Nothing is downloaded until you choose Download. You can download it later from Settings.": "Nic se nestáhne, dokud nezvolíte Stáhnout. Později ji můžete stáhnout v Nastavení.",
|
||||
"Lens profiles": "Profily objektivů",
|
||||
"Lens profiles are read from the folder LIGHTCRAFT_LENSFUN_DIR names.": "Profily objektivů se čtou ze složky, kterou určuje LIGHTCRAFT_LENSFUN_DIR.",
|
||||
"The lens database is installed.": "Databáze objektivů je nainstalována.",
|
||||
"The lens database is not installed (about {size} MB).": "Databáze objektivů není nainstalována (asi {size} MB).",
|
||||
"Lens database": "Databáze objektivů",
|
||||
"Download Lens Database": "Stáhnout databázi objektivů",
|
||||
"Offer the lens database download when LightCraft starts": "Nabídnout stažení databáze objektivů při spuštění LightCraftu",
|
||||
"lensfun's open lens database (CC BY-SA 3.0) corrects distortion and vignetting for many lenses. It is only downloaded when you choose to.": "Otevřená databáze objektivů lensfun (CC BY-SA 3.0) opravuje zkreslení a vinětaci mnoha objektivů. Stáhne se jen, když to zvolíte.",
|
||||
"Import Thumbnail Size": "Velikost miniatur při importu",
|
||||
"Face recognition is on": "Rozpoznávání obličejů je zapnuto",
|
||||
"Face recognition is off. Turn it on to get name suggestions and see look-alike faces together.": "Rozpoznávání obličejů je vypnuto. Zapněte je, abyste dostávali návrhy jmen a viděli podobné obličeje pohromadě.",
|
||||
|
||||
@@ -313,6 +313,9 @@
|
||||
"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{})",
|
||||
"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",
|
||||
"Photo: {name}": "Foto: {name}",
|
||||
"Panel layout could not be restored: {error}": "Die Bereichsanordnung konnte nicht wiederhergestellt werden: {error}",
|
||||
"Panel move was not applied: {error}": "Das Verschieben des Bereichs wurde nicht ausgeführt: {error}",
|
||||
|
||||
@@ -3487,6 +3487,25 @@
|
||||
"The photos are imported once it ends": "Die Fotos werden danach importiert",
|
||||
"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",
|
||||
"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.",
|
||||
"Lens Profiles": "Objektivprofile",
|
||||
"Download lens profiles?": "Objektivprofile laden?",
|
||||
"LightCraft can correct distortion and vignetting for many lenses using the open lens database (lensfun, about {size} MB).": "LightCraft kann Verzeichnung und Vignettierung für viele Objektive mit der offenen Objektivdatenbank korrigieren (lensfun, etwa {size} MB).",
|
||||
"Nothing is downloaded until you choose Download. You can download it later from Settings.": "Nichts wird geladen, bevor Sie „Laden“ wählen. Sie können sie später in den Einstellungen laden.",
|
||||
"Lens profiles": "Objektivprofile",
|
||||
"Lens profiles are read from the folder LIGHTCRAFT_LENSFUN_DIR names.": "Objektivprofile werden aus dem Ordner gelesen, den LIGHTCRAFT_LENSFUN_DIR nennt.",
|
||||
"The lens database is installed.": "Die Objektivdatenbank ist installiert.",
|
||||
"The lens database is not installed (about {size} MB).": "Die Objektivdatenbank ist nicht installiert (etwa {size} MB).",
|
||||
"Lens database": "Objektivdatenbank",
|
||||
"Download Lens Database": "Objektivdatenbank laden",
|
||||
"Offer the lens database download when LightCraft starts": "Beim Start von LightCraft anbieten, die Objektivdatenbank zu laden",
|
||||
"lensfun's open lens database (CC BY-SA 3.0) corrects distortion and vignetting for many lenses. It is only downloaded when you choose to.": "Die offene Objektivdatenbank von lensfun (CC BY-SA 3.0) korrigiert Verzeichnung und Vignettierung für viele Objektive. Sie wird nur geladen, wenn Sie es wählen.",
|
||||
"Import Thumbnail Size": "Vorschaubildgröße beim Import",
|
||||
"Face recognition is on": "Gesichtserkennung ist eingeschaltet",
|
||||
"Face recognition is off. Turn it on to get name suggestions and see look-alike faces together.": "Gesichtserkennung ist ausgeschaltet. Schalte sie ein, um Namensvorschläge zu erhalten und ähnliche Gesichter zusammen zu sehen.",
|
||||
|
||||
@@ -313,6 +313,9 @@
|
||||
"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{})",
|
||||
"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",
|
||||
"Photo: {name}": "Foto: {name}",
|
||||
"Panel layout could not be restored: {error}": "No se pudo restablecer la disposición de paneles: {error}",
|
||||
"Panel move was not applied: {error}": "No se aplicó el movimiento del panel: {error}",
|
||||
|
||||
@@ -2406,6 +2406,25 @@
|
||||
"The photos are imported once it ends": "Las fotos se importarán después",
|
||||
"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",
|
||||
"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.",
|
||||
"Lens Profiles": "Perfiles de objetivo",
|
||||
"Download lens profiles?": "¿Descargar perfiles de objetivo?",
|
||||
"LightCraft can correct distortion and vignetting for many lenses using the open lens database (lensfun, about {size} MB).": "LightCraft puede corregir la distorsión y el viñeteado de muchos objetivos con la base de datos abierta de objetivos (lensfun, unos {size} MB).",
|
||||
"Nothing is downloaded until you choose Download. You can download it later from Settings.": "No se descarga nada hasta que elija Descargar. Puede descargarla más tarde desde Ajustes.",
|
||||
"Lens profiles": "Perfiles de objetivo",
|
||||
"Lens profiles are read from the folder LIGHTCRAFT_LENSFUN_DIR names.": "Los perfiles de objetivo se leen de la carpeta que indica LIGHTCRAFT_LENSFUN_DIR.",
|
||||
"The lens database is installed.": "La base de datos de objetivos está instalada.",
|
||||
"The lens database is not installed (about {size} MB).": "La base de datos de objetivos no está instalada (unos {size} MB).",
|
||||
"Lens database": "Base de datos de objetivos",
|
||||
"Download Lens Database": "Descargar base de datos de objetivos",
|
||||
"Offer the lens database download when LightCraft starts": "Ofrecer la descarga de la base de datos de objetivos al iniciar LightCraft",
|
||||
"lensfun's open lens database (CC BY-SA 3.0) corrects distortion and vignetting for many lenses. It is only downloaded when you choose to.": "La base de datos abierta de objetivos de lensfun (CC BY-SA 3.0) corrige la distorsión y el viñeteado de muchos objetivos. Solo se descarga si usted lo elige.",
|
||||
"Thumbnail size": "Tamaño de las miniaturas",
|
||||
"Import Thumbnail Size": "Tamaño de las miniaturas de importación",
|
||||
"Face recognition is on": "El reconocimiento facial está activado",
|
||||
|
||||
@@ -2374,6 +2374,18 @@
|
||||
"Dropped Files": "Pudotetut tiedostot",
|
||||
"Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä",
|
||||
"Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta",
|
||||
"Lens Profiles": "Objektiiviprofiilit",
|
||||
"Download lens profiles?": "Ladataanko objektiiviprofiilit?",
|
||||
"LightCraft can correct distortion and vignetting for many lenses using the open lens database (lensfun, about {size} MB).": "LightCraft voi korjata monien objektiivien vääristymän ja vinjetoinnin avoimen objektiivitietokannan avulla (lensfun, noin {size} Mt).",
|
||||
"Nothing is downloaded until you choose Download. You can download it later from Settings.": "Mitään ei ladata, ennen kuin valitset Lataa. Voit ladata sen myöhemmin asetuksista.",
|
||||
"Lens profiles": "Objektiiviprofiilit",
|
||||
"Lens profiles are read from the folder LIGHTCRAFT_LENSFUN_DIR names.": "Objektiiviprofiilit luetaan kansiosta, jonka LIGHTCRAFT_LENSFUN_DIR nimeää.",
|
||||
"The lens database is installed.": "Objektiivitietokanta on asennettu.",
|
||||
"The lens database is not installed (about {size} MB).": "Objektiivitietokantaa ei ole asennettu (noin {size} Mt).",
|
||||
"Lens database": "Objektiivitietokanta",
|
||||
"Download Lens Database": "Lataa objektiivitietokanta",
|
||||
"Offer the lens database download when LightCraft starts": "Tarjoa objektiivitietokannan latausta, kun LightCraft käynnistyy",
|
||||
"lensfun's open lens database (CC BY-SA 3.0) corrects distortion and vignetting for many lenses. It is only downloaded when you choose to.": "lensfunin avoin objektiivitietokanta (CC BY-SA 3.0) korjaa monien objektiivien vääristymän ja vinjetoinnin. Se ladataan vain, jos valitset niin.",
|
||||
"Import Thumbnail Size": "Tuonnin pienoiskuvien koko",
|
||||
"Face recognition is on": "Kasvojentunnistus on päällä",
|
||||
"Face recognition is off. Turn it on to get name suggestions and see look-alike faces together.": "Kasvojentunnistus on pois päältä. Ota se käyttöön, niin saat nimiehdotuksia ja näet samannäköiset kasvot yhdessä.",
|
||||
|
||||
@@ -313,6 +313,9 @@
|
||||
"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{})",
|
||||
"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",
|
||||
"Photo: {name}": "Photo : {name}",
|
||||
"Panel layout could not be restored: {error}": "La disposition des panneaux n'a pas pu être restaurée : {error}",
|
||||
"Panel move was not applied: {error}": "Le déplacement du panneau n'a pas été appliqué : {error}",
|
||||
|
||||
@@ -2437,6 +2437,25 @@
|
||||
"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 :",
|
||||
"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.",
|
||||
"Lens Profiles": "Profils d’objectif",
|
||||
"Download lens profiles?": "Télécharger les profils d’objectif ?",
|
||||
"LightCraft can correct distortion and vignetting for many lenses using the open lens database (lensfun, about {size} MB).": "LightCraft peut corriger la distorsion et le vignetage de nombreux objectifs grâce à la base d’objectifs ouverte (lensfun, environ {size} Mo).",
|
||||
"Nothing is downloaded until you choose Download. You can download it later from Settings.": "Rien n’est téléchargé tant que vous ne choisissez pas Télécharger. Vous pourrez la télécharger plus tard dans les Réglages.",
|
||||
"Lens profiles": "Profils d’objectif",
|
||||
"Lens profiles are read from the folder LIGHTCRAFT_LENSFUN_DIR names.": "Les profils d’objectif sont lus dans le dossier indiqué par LIGHTCRAFT_LENSFUN_DIR.",
|
||||
"The lens database is installed.": "La base d’objectifs est installée.",
|
||||
"The lens database is not installed (about {size} MB).": "La base d’objectifs n’est pas installée (environ {size} Mo).",
|
||||
"Lens database": "Base d’objectifs",
|
||||
"Download Lens Database": "Télécharger la base d’objectifs",
|
||||
"Offer the lens database download when LightCraft starts": "Proposer le téléchargement de la base d’objectifs au démarrage de LightCraft",
|
||||
"lensfun's open lens database (CC BY-SA 3.0) corrects distortion and vignetting for many lenses. It is only downloaded when you choose to.": "La base d’objectifs ouverte de lensfun (CC BY-SA 3.0) corrige la distorsion et le vignetage de nombreux objectifs. Elle n’est téléchargée que si vous le choisissez.",
|
||||
"Face recognition is on": "La reconnaissance des visages est activée",
|
||||
"Face recognition is off. Turn it on to get name suggestions and see look-alike faces together.": "La reconnaissance des visages est désactivée. Activez-la pour obtenir des suggestions de noms et voir ensemble les visages qui se ressemblent.",
|
||||
"Turn on": "Activer",
|
||||
|
||||
@@ -2387,6 +2387,18 @@
|
||||
"Dropped Files": "File yang dijatuhkan",
|
||||
"Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto",
|
||||
"Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan",
|
||||
"Lens Profiles": "Profil lensa",
|
||||
"Download lens profiles?": "Unduh profil lensa?",
|
||||
"LightCraft can correct distortion and vignetting for many lenses using the open lens database (lensfun, about {size} MB).": "LightCraft dapat mengoreksi distorsi dan vinyet untuk banyak lensa menggunakan basis data lensa terbuka (lensfun, sekitar {size} MB).",
|
||||
"Nothing is downloaded until you choose Download. You can download it later from Settings.": "Tidak ada yang diunduh sampai Anda memilih Unduh. Anda dapat mengunduhnya nanti dari Pengaturan.",
|
||||
"Lens profiles": "Profil lensa",
|
||||
"Lens profiles are read from the folder LIGHTCRAFT_LENSFUN_DIR names.": "Profil lensa dibaca dari folder yang disebut LIGHTCRAFT_LENSFUN_DIR.",
|
||||
"The lens database is installed.": "Basis data lensa sudah terpasang.",
|
||||
"The lens database is not installed (about {size} MB).": "Basis data lensa belum terpasang (sekitar {size} MB).",
|
||||
"Lens database": "Basis data lensa",
|
||||
"Download Lens Database": "Unduh Basis Data Lensa",
|
||||
"Offer the lens database download when LightCraft starts": "Tawarkan unduhan basis data lensa saat LightCraft dimulai",
|
||||
"lensfun's open lens database (CC BY-SA 3.0) corrects distortion and vignetting for many lenses. It is only downloaded when you choose to.": "Basis data lensa terbuka lensfun (CC BY-SA 3.0) mengoreksi distorsi dan vinyet untuk banyak lensa. Basis data ini hanya diunduh jika Anda memilihnya.",
|
||||
"Thumbnail size": "Ukuran gambar mini",
|
||||
"Import Thumbnail Size": "Ukuran Gambar Mini Impor",
|
||||
"Face recognition is on": "Pengenalan wajah aktif",
|
||||
|
||||
@@ -313,6 +313,9 @@
|
||||
"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})",
|
||||
"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",
|
||||
"Photo: {name}": "Foto: {name}",
|
||||
"Panel layout could not be restored: {error}": "Non è stato possibile ripristinare la disposizione dei pannelli: {error}",
|
||||
"Panel move was not applied: {error}": "Lo spostamento del pannello non è stato applicato: {error}",
|
||||
|
||||
@@ -2450,6 +2450,25 @@
|
||||
"The photos are imported once it ends": "Le foto verranno importate dopo",
|
||||
"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",
|
||||
"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.",
|
||||
"Lens Profiles": "Profili obiettivo",
|
||||
"Download lens profiles?": "Scaricare i profili degli obiettivi?",
|
||||
"LightCraft can correct distortion and vignetting for many lenses using the open lens database (lensfun, about {size} MB).": "LightCraft può correggere distorsione e vignettatura per molti obiettivi usando il database aperto degli obiettivi (lensfun, circa {size} MB).",
|
||||
"Nothing is downloaded until you choose Download. You can download it later from Settings.": "Non viene scaricato nulla finché non scegli Scarica. Puoi scaricarlo più tardi dalle Impostazioni.",
|
||||
"Lens profiles": "Profili obiettivo",
|
||||
"Lens profiles are read from the folder LIGHTCRAFT_LENSFUN_DIR names.": "I profili obiettivo vengono letti dalla cartella indicata da LIGHTCRAFT_LENSFUN_DIR.",
|
||||
"The lens database is installed.": "Il database degli obiettivi è installato.",
|
||||
"The lens database is not installed (about {size} MB).": "Il database degli obiettivi non è installato (circa {size} MB).",
|
||||
"Lens database": "Database obiettivi",
|
||||
"Download Lens Database": "Scarica database obiettivi",
|
||||
"Offer the lens database download when LightCraft starts": "Offri il download del database obiettivi all’avvio di LightCraft",
|
||||
"lensfun's open lens database (CC BY-SA 3.0) corrects distortion and vignetting for many lenses. It is only downloaded when you choose to.": "Il database aperto degli obiettivi di lensfun (CC BY-SA 3.0) corregge distorsione e vignettatura per molti obiettivi. Viene scaricato solo se lo scegli.",
|
||||
"A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Una diapositiva resta visibile per la sua durata, dissolvenza d'ingresso compresa. Con una dissolvenza di 0 il passaggio è netto.",
|
||||
"Add a cell": "Aggiungi una cella",
|
||||
"cells": "celle",
|
||||
|
||||
@@ -313,6 +313,9 @@
|
||||
"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}",
|
||||
"Profile: {} (lens database)": "プロファイル: {}(レンズデータベース)",
|
||||
"No lens profile found for {}.": "{} のレンズプロファイルが見つかりません。",
|
||||
"{n} photos got their lens profile": "{n} 枚の写真にレンズプロファイルが適用されました",
|
||||
"Photo: {name}": "写真: {name}",
|
||||
"Panel layout could not be restored: {error}": "パネル配置を復元できませんでした:{error}",
|
||||
"Panel move was not applied: {error}": "パネルの移動は適用されませんでした:{error}",
|
||||
|
||||
@@ -2404,6 +2404,25 @@
|
||||
"The photos are imported once it ends": "終わりしだい写真を読み込みます",
|
||||
"An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください",
|
||||
"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 は次回の起動時に再試行します。",
|
||||
"Lens Profiles": "レンズプロファイル",
|
||||
"Download lens profiles?": "レンズプロファイルをダウンロードしますか?",
|
||||
"LightCraft can correct distortion and vignetting for many lenses using the open lens database (lensfun, about {size} MB).": "LightCraft はオープンなレンズデータベース(lensfun、約 {size} MB)を使って、多くのレンズの歪曲収差と周辺減光を補正できます。",
|
||||
"Nothing is downloaded until you choose Download. You can download it later from Settings.": "「ダウンロード」を選ぶまで何もダウンロードしません。あとで設定からダウンロードすることもできます。",
|
||||
"Lens profiles": "レンズプロファイル",
|
||||
"Lens profiles are read from the folder LIGHTCRAFT_LENSFUN_DIR names.": "レンズプロファイルは LIGHTCRAFT_LENSFUN_DIR が指定するフォルダーから読み込まれます。",
|
||||
"The lens database is installed.": "レンズデータベースはインストール済みです。",
|
||||
"The lens database is not installed (about {size} MB).": "レンズデータベースはインストールされていません(約 {size} MB)。",
|
||||
"Lens database": "レンズデータベース",
|
||||
"Download Lens Database": "レンズデータベースをダウンロード",
|
||||
"Offer the lens database download when LightCraft starts": "LightCraft の起動時にレンズデータベースのダウンロードを提案する",
|
||||
"lensfun's open lens database (CC BY-SA 3.0) corrects distortion and vignetting for many lenses. It is only downloaded when you choose to.": "lensfun のオープンなレンズデータベース(CC BY-SA 3.0)は多くのレンズの歪曲収差と周辺減光を補正します。選んだときにだけダウンロードされます。",
|
||||
"Thumbnail size": "サムネイルのサイズ",
|
||||
"Import Thumbnail Size": "読み込みサムネイルのサイズ",
|
||||
"Face recognition is on": "顔認識をオンにしました",
|
||||
|
||||
@@ -316,6 +316,9 @@
|
||||
"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}",
|
||||
"Profile: {} (lens database)": "პროფილი: {} (ობიექტივების ბაზა)",
|
||||
"No lens profile found for {}.": "{}-ისთვის ობიექტივის პროფილი ვერ მოიძებნა.",
|
||||
"{n} photos got their lens profile": "ობიექტივის პროფილი მიიღო {n} ფოტომ",
|
||||
"Photo: {name}": "ფოტო: {name}",
|
||||
"Panel layout could not be restored: {error}": "პანელების განლაგების აღდგენა ვერ მოხერხდა: {error}",
|
||||
"Panel move was not applied: {error}": "პანელის გადატანა არ შესრულდა: {error}",
|
||||
|
||||
@@ -2781,6 +2781,25 @@
|
||||
"The photos are imported once it ends": "ფოტოები ამის შემდეგ შემოიტანება",
|
||||
"An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება",
|
||||
"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 კვლავ სცდის შემდეგ გაშვებისას.",
|
||||
"Lens Profiles": "ობიექტივის პროფილები",
|
||||
"Download lens profiles?": "ჩამოვტვირთო ობიექტივის პროფილები?",
|
||||
"LightCraft can correct distortion and vignetting for many lenses using the open lens database (lensfun, about {size} MB).": "LightCraft-ს შეუძლია მრავალი ობიექტივის დისტორსიისა და ვინეტირების გასწორება ობიექტივების ღია ბაზით (lensfun, დაახლოებით {size} მბ).",
|
||||
"Nothing is downloaded until you choose Download. You can download it later from Settings.": "არაფერი ჩამოიტვირთება, სანამ „ჩამოტვირთვას“ არ აირჩევთ. მოგვიანებით მისი ჩამოტვირთვა პარამეტრებიდანაც შეგიძლიათ.",
|
||||
"Lens profiles": "ობიექტივის პროფილები",
|
||||
"Lens profiles are read from the folder LIGHTCRAFT_LENSFUN_DIR names.": "ობიექტივის პროფილები იკითხება საქაღალდიდან, რომელსაც LIGHTCRAFT_LENSFUN_DIR ასახელებს.",
|
||||
"The lens database is installed.": "ობიექტივების ბაზა დაყენებულია.",
|
||||
"The lens database is not installed (about {size} MB).": "ობიექტივების ბაზა არ არის დაყენებული (დაახლოებით {size} მბ).",
|
||||
"Lens database": "ობიექტივების ბაზა",
|
||||
"Download Lens Database": "ობიექტივების ბაზის ჩამოტვირთვა",
|
||||
"Offer the lens database download when LightCraft starts": "LightCraft-ის გაშვებისას ობიექტივების ბაზის ჩამოტვირთვის შეთავაზება",
|
||||
"lensfun's open lens database (CC BY-SA 3.0) corrects distortion and vignetting for many lenses. It is only downloaded when you choose to.": "lensfun-ის ობიექტივების ღია ბაზა (CC BY-SA 3.0) ასწორებს მრავალი ობიექტივის დისტორსიასა და ვინეტირებას. ის მხოლოდ თქვენი არჩევით ჩამოიტვირთება.",
|
||||
"(not in the library)": "(ბიბლიოთეკაში არ არის)",
|
||||
"1 raw can now be developed from its raw data": "1 RAW ფაილის დამუშავება ახლა მისი RAW მონაცემებიდან შეიძლება",
|
||||
"150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · მიმდინარე ცვლილებები · ჩატევა ჩამოჭრის გარეშე",
|
||||
|
||||
@@ -313,6 +313,9 @@
|
||||
"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}",
|
||||
"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}",
|
||||
"Photo: {name}": "Zdjęcie: {name}",
|
||||
"Panel layout could not be restored: {error}": "Nie udało się przywrócić układu paneli: {error}",
|
||||
"Panel move was not applied: {error}": "Przeniesienie panelu nie zostało zastosowane: {error}",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user