diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 26725e70..900c5257 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -10,22 +10,21 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_active, has_selection}; use crate::{Result, Session}; -/// A free `.dng` (then `-2.dng`…) next to `path`. -fn dng_path(path: &str) -> String { +/// `.dng`, then `-2.dng`, `-3.dng`… next to `path`. +fn dng_names(path: &str) -> impl Iterator { let p = Path::new(path); - let dir = p.parent().unwrap_or(Path::new("")); + let dir = p.parent().unwrap_or(Path::new("")).to_path_buf(); let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into()); - let mut out = dir.join(format!("{stem}.dng")); - let mut n = 2; - while out.exists() { - out = dir.join(format!("{stem}-{n}.dng")); - n += 1; - } - out.to_string_lossy().to_string() + (1u64..).map(move |n| if n == 1 { dir.join(format!("{stem}.dng")) } else { dir.join(format!("{stem}-{n}.dng")) }) } -/// Write the DNG for raw file `path` (develop settings in `packet`); returns the new path. -/// An empty `packet` writes no XMP. +/// Write the DNG for raw file `path` (develop settings in `packet`) next to it under a free name; +/// returns the new path. An empty `packet` writes no XMP. +/// +/// The DNG is checked before anyone relies on it (Convert to DNG relinks the photo to it, Copy as +/// DNG deletes the raw copy): the encoded file must decode to the same raw data, and it is +/// written to a temp file, synced, read back identical and only then given its name — never +/// replacing an existing file. On any failure no DNG is left behind and the raw stays in use. pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::result::Result { let bytes = match &s.media.file_bytes { Some(r) => r(path)?, @@ -35,9 +34,21 @@ pub(crate) fn write_dng_for(s: &Session, path: &str, packet: String) -> std::res drop(bytes); let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() }) .map_err(|e| e.to_string())?; - let out = dng_path(path); - std::fs::write(&out, dng).map_err(|e| format!("{out}: {e}"))?; - Ok(out) + let back = lightcraft_raw::decode(&dng).map_err(|e| format!("{path}: the DNG written for it doesn't decode ({e}); the raw is kept"))?; + let same = match (&back.data, &raw.data) { + (lightcraft_raw::RawData::U16(a), lightcraft_raw::RawData::U16(b)) => a == b, + (lightcraft_raw::RawData::F32(a), lightcraft_raw::RawData::F32(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()) + } + _ => false, + }; + if (back.width, back.height, back.cpp) != (raw.width, raw.height, raw.cpp) || !same { + return Err(format!("{path}: the DNG written for it doesn't hold the same raw data; the raw is kept")); + } + drop((raw, back)); + let out = lightcraft_catalog::safe_file::write_new_unique(&mut dng_names(path), &dng) + .map_err(|e| format!("{path}: could not write its DNG ({e}); the raw is kept"))?; + Ok(out.to_string_lossy().to_string()) } fn convert(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 8da19ce3..44b2cebd 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -102,7 +102,7 @@ fn smart(s: &mut Session, p: &Value) -> Result { let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?; let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false); let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; - let (mut built, mut removed, mut failed) = (0usize, 0usize, Vec::new()); + let (mut built, mut repaired, mut removed, mut failed) = (0usize, 0usize, 0usize, Vec::new()); if !discard { // a chosen folder on another drive must be there: never recreate it on this one if s.smart_previews_dir.is_some() { @@ -123,20 +123,27 @@ fn smart(s: &mut Session, p: &Value) -> Result { } continue; } - if path.exists() { + // a complete proxy is kept; a missing or damaged one (cut short by a crash or a full + // drive) is (re)built + let damaged = path.exists(); + if damaged && crate::smart::is_valid(&path) { built += 1; continue; } + // atomic: a failed write leaves no partial proxy that would pass for a built one let r = s .source_now(id, crate::media::SourceLevel::Preview) .and_then(|src| crate::smart::encode(&src)) - .and_then(|b| std::fs::write(&path, b).map_err(|e| e.to_string())); + .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display()))); match r { - Ok(()) => built += 1, + Ok(()) => { + built += 1; + repaired += usize::from(damaged); + } Err(e) => failed.push(json!([id.0, e])), } } - Ok(json!({"built": built, "removed": removed, "failed": failed})) + Ok(json!({"built": built, "repaired": repaired, "removed": removed, "failed": failed})) } /// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be @@ -205,7 +212,7 @@ fn smart_location(s: &mut Session, p: &Value) -> Result { /// Whether photo `id` has a smart preview. pub fn has_smart_preview(s: &Session, id: lightcraft_catalog::PhotoId) -> bool { match (&s.media.smart_dir, s.catalog.photo(id)) { - (Some(dir), Some(p)) => dir.join(crate::smart::file_name(p)).exists(), + (Some(dir), Some(p)) => crate::smart::is_valid(&dir.join(crate::smart::file_name(p))), _ => false, } } @@ -218,7 +225,7 @@ pub fn specs() -> Vec { "Build Smart Previews", [], None, - "{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline → {built, removed, failed}", + "{ids?, discard?: bool} — build (or discard) the smart previews of the selected photos (else all in view): compact proxies in the library that keep photos editable and exportable (at proxy size) while their originals are offline; a damaged proxy (cut short) is rebuilt → {built, repaired, removed, failed}", always, smart ), diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 4841533b..18e67faf 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -290,7 +290,7 @@ impl MediaCache { && !std::path::Path::new(path).exists() { let sp = dir.join(crate::smart::file_name(p)); - if sp.exists() { + if crate::smart::is_valid(&sp) { return SourceRef::Smart { path: sp }; } } diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 24c65489..53c7f546 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -304,15 +304,13 @@ fn apply_auto(s: &mut DevelopSettings, src: &Rgb32f, info: &SourceInfo) { s.color.saturation = a.saturation; } -/// A free file name `dir/-.dng` (`-2`, `-3`… when taken). -fn output_path(first: &str, suffix: &str) -> String { +/// `dir/-.dng`, then `-2`, `-3`… (the candidates for a merge's output). +fn output_names(first: &str, suffix: &str) -> impl Iterator { let p = std::path::Path::new(first); let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default(); let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into()); - let cand = |k: u64| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") }); - // (`u64::MAX` taken names can't exist; the last candidate is the fallback) - let free = (1..u64::MAX).map(cand).find(|c| !c.exists()).unwrap_or_else(|| cand(u64::MAX)); - free.to_string_lossy().to_string() + let suffix = suffix.to_string(); + (1u64..).map(move |k| dir.join(if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") })) } /// Parse `merge.*` command parameters into a kind and finishing options. @@ -367,8 +365,11 @@ impl Session { /// apply Auto Settings / the auto crop. Returns `{id, path, …info}`. pub fn finish_merge(&mut self, job: &MergeJob, out: MergeOutput) -> Result { let first = &job.sources.first().ok_or_else(|| EngineError::Other("nothing merged".into()))?.1; - let path = output_path(first, job.kind.suffix()); - std::fs::write(&path, &out.dng).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // a new file under a free name (never replacing one), complete and synced before it appears + let path = lightcraft_catalog::safe_file::write_new_unique(&mut output_names(first, job.kind.suffix()), &out.dng) + .map_err(|e| EngineError::Other(format!("could not write the merged DNG next to {first}: {e}")))? + .to_string_lossy() + .to_string(); let report = crate::import::import(self, std::slice::from_ref(&path), crate::import::ImportMode::Add)?; let id = report .imported diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 79560c96..038f3330 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -130,11 +130,16 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec) { Existing::Discard => std::fs::remove_file(&src), _ => { let dst = to.join(e.file_name()); - if dst.exists() { - // named by content: the copy already there is the same preview + if is_valid(&dst) { + // named by content: the complete copy already there is the same preview std::fs::remove_file(&src) } else { - std::fs::rename(&src, &dst).or_else(|_| std::fs::copy(&src, &dst).and_then(|_| std::fs::remove_file(&src))) + // across drives: copied atomically (never a partial proxy at the new place) + std::fs::rename(&src, &dst).or_else(|_| { + std::fs::read(&src) + .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b)) + .and_then(|()| std::fs::remove_file(&src)) + }) } } }; @@ -146,6 +151,29 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec) { (done, failed) } +/// A cheap check that the smart preview at `path` is complete: our header, then a JPEG from its +/// start marker to its end marker. A file cut short by a crash, a full drive or an unplugged one +/// fails it (and is then rebuilt by Build Smart Previews, not counted as there). +pub fn is_valid(path: &Path) -> bool { + use std::io::{Read, Seek, SeekFrom}; + let Ok(mut f) = std::fs::File::open(path) else { return false }; + let mut head = Vec::with_capacity(256); + if (&mut f).take(256).read_to_end(&mut head).is_err() { + return false; + } + let Some(rest) = head.strip_prefix(MAGIC) else { return false }; + let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false }; + let size_ok = rest + .get(..nl) + .and_then(|h| serde_json::from_slice::(h).ok()) + .is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0)); + if !size_ok || rest.get(nl + 1..nl + 3) != Some(&[0xFF, 0xD8][..]) { + return false; + } + let mut end = [0u8; 2]; + f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9] +} + /// Load the proxy at `path`. pub fn load(path: &Path) -> Result, String> { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; @@ -193,6 +221,27 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { + let base = temp("valid"); + let (a, b) = (base.join("a"), base.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let good = encode(&img).unwrap(); + std::fs::write(a.join("1.lcsp"), &good).unwrap(); + assert!(is_valid(&a.join("1.lcsp"))); + for cut in [good.len() - 1, good.len() / 2, 10, 0] { + std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap(); + assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}"); + } + assert!(!is_valid(&b.join("missing.lcsp"))); + // moving onto a damaged copy replaces it instead of deleting the good one + assert_eq!(migrate(&a, &b, Existing::Move).0, 1); + assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn roundtrip_keeps_the_picture() { let img = lightcraft_scenes::demo_library()[0].render(160, 100); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 6be7ce9a..cd5b0e92 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -352,6 +352,44 @@ fn smart_previews_stand_in_for_offline_originals() { let _ = std::fs::remove_dir_all(&lib); } +/// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed +/// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not +/// reported as present and is rebuilt. +#[test] +fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() { + let src = temp_dir("smartdmg-src"); + let lib = temp_dir("smartdmg-lib"); + write_png(&src.join("a.png"), 5); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let file = dir.join(crate::smart::file_name(s.catalog.photo(id).unwrap())); + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(64); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["failed"].as_array().map(Vec::len)), (Some(0), Some(1)), "{r}"); + } + assert!(!file.exists(), "no partial proxy"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no temp file left"); + assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false); + + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0)), "{r}"); + assert!(crate::smart::is_valid(&file)); + // cut short, as by a crash before the fix + let full = std::fs::read(&file).unwrap(); + std::fs::write(&file, &full[..full.len() / 2]).unwrap(); + assert!(!crate::smart::is_valid(&file)); + assert_eq!(s.execute("photo.smartPreview", &json!({})).unwrap()["smartPreview"], false, "not counted as there"); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole"); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} + /// LUT profiles: a .cube imports as a creative profile (into the library), renders differently /// (Amount 0 = no change), is listed in the profile browser and comes back with the library. #[test] diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 00b8e3b0..f31c3d48 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -329,6 +329,55 @@ fn convert_raw_to_dng() { let _ = std::fs::remove_dir_all(&dir); } +/// Issue #106: Convert to DNG wrote straight to the final name (no temp file, sync or check) and +/// relinked the photo even when the write was cut short. A failed write now leaves no DNG and +/// the photo on its raw; a good one is verified and never replaces an existing file. +#[test] +fn convert_to_dng_is_verified_and_atomic() { + let dir = temp_dir("todng-safe"); + // a synthetic raw (stored as DNG, catalogued as a NEF so Convert to DNG takes it) + let raw = dir.join("shot.dng"); + let bytes = synthetic_dng_with(None, Default::default()); + std::fs::write(&raw, &bytes).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let path = raw.to_string_lossy().to_string(); + s.catalog + .apply(lightcraft_catalog::Op::Relink { + id, + file_name: "shot.dng".into(), + source: lightcraft_catalog::Source::File { path: path.clone() }, + format: Some("NEF".into()), + }) + .unwrap(); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let names = || { + let mut v: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + v.sort(); + v + }; + { + // the drive fills up part-way + let _fault = lightcraft_catalog::safe_file::fail_writes_after(200); + let r = s.execute("photo.convertToDng", &json!({})).unwrap(); + assert_eq!(r["converted"].as_array().unwrap().len(), 0, "{r}"); + assert!(r["skipped"][0][1].as_str().unwrap().contains("the raw is kept"), "{r}"); + } + assert_eq!(names(), ["shot.dng"], "no partial DNG"); + assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: path.clone() }, "not relinked"); + // a good conversion: a new name (the existing file is not replaced), decodable, relinked + let r = s.execute("photo.convertToDng", &json!({})).unwrap(); + let out = r["converted"][0]["path"].as_str().unwrap().to_string(); + assert!(out.ends_with("shot-2.dng"), "{r}"); + assert_eq!(std::fs::read(&raw).unwrap(), bytes, "the raw is untouched"); + let back = lightcraft_raw::decode(&std::fs::read(&out).unwrap()).unwrap(); + assert_eq!(back.data, lightcraft_raw::decode(&bytes).unwrap().data); + assert_eq!(s.catalog.photo(id).unwrap().source, lightcraft_catalog::Source::File { path: out }); + assert_eq!(names(), ["shot-2.dng", "shot.dng"], "no temp file left"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Edit in External Editor (engine half): a 16-bit TIFF `-Edit` copy with the edits, next to /// the original, added and stacked on top of it; a second one doesn't overwrite the first. #[test] diff --git a/docs/parity.md b/docs/parity.md index 443fe056..8f7c38c7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -128,7 +128,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | -| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched) | +| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | ## B. Library management (LIB) @@ -480,7 +480,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CLOUD-SYNC | Cloud sync | OOS | 🚫 | | | -| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews: ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive. The thumbnail cache stays in the library | +| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | ✅ | `cmd:library.smartPreviews`, `cmd:library.smartPreviewsLocation`, `cmd:photo.smartPreview`, `crates/engine/src/smart.rs`, `crates/ui-egui/src/panels/settings.rs` | File ▸ Previews ▸ Build / Discard Smart Previews: ~1 MB proxies in the library; with the original offline the photo renders, edits and exports (at proxy size) from its proxy; Info shows the status; the proxy folder is chosen per library (Settings → Performance → Smart previews, saved in the library): the effective path, count and size are shown, changing it needs a choice for the proxies already built (move / leave / delete), and an unavailable or unwritable folder is an error, never a fallback to the library drive; proxies are written atomically and a damaged one (cut short by a crash or a full drive) doesn't count as built, is rebuilt (`repaired`) and is never moved over a good one (issue #106). The thumbnail cache stays in the library | | LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | | | | LR-AI-CREDITS | Generative credits | OOS | 🚫 | | | @@ -728,7 +728,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (same name and size anywhere in a folder, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | -| LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable | +| LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) | | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (progress toasts, stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | diff --git a/docs/whats-new.md b/docs/whats-new.md index 42baa1bf..6f534781 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -17,6 +17,11 @@ Exported files are written to a temp file, synced and then renamed into place, so a full disk or an unplugged drive never leaves a truncated file; the XMP sidecar of an "Original" export follows the "If file exists" choice too. +- Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue + #106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its + name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write + leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or + a full drive) no longer counts as built and Build Smart Previews replaces it. - Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter layout is read from each file instead of assumed.