From 1169a1beda406a63970bb9783de8fcad01b8a49c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 02:57:21 -0400 Subject: [PATCH] LR-IMP-FORMATS: mark raws shown from their embedded preview as "preview only" Raw variants the decoder can't read yet (e.g. Nikon Huffman-compressed NEF from a D3200/D5100) silently fell back to the camera's embedded JPEG, so users edited a JPEG with the camera's picture style (e.g. Monochrome) baked in without being told. - catalog: Photo.preview_only (serde default; old catalogs load), set / cleared by Op::SetContent with an exact inverse; Photo::develops_raw(). - engine: ProbeInfo.preview_only carries the decoder's Unsupported reason into import, the import review (previewOnly) and catalog.query (previewOnly); photo.inspect includes it. photo.reload and photo.relink re-probe and clear/set it when the file decodes (or not) now. - source_info: a preview-only raw is a rendered source (relative WB scale, display tone curve) instead of raw:true/5500 K + filmic tone map on a display-referred JPEG; import uses the JPEG default preset for it; the Edit panel shows the rendered-file WB sliders. - UI: "Preview" badge on grid thumbnails, a pill on the loupe, a notice in Edit and Info (amber caution token, Info icon drawn in code). - Tests: catalog serde/undo, engine import -> reopen -> reload/relink, headless UI badge/notice widgets. Refs #10 Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 7 +++- crates/catalog/src/model.rs | 12 ++++++ crates/catalog/src/tests.rs | 33 ++++++++++++++++ crates/engine/src/cmd/convert.rs | 27 ++++++++++--- crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/cmd/missing.rs | 10 ++++- crates/engine/src/cmd/query.rs | 2 + crates/engine/src/files.rs | 59 +++++++++++++++++++++++++++++ crates/engine/src/import.rs | 9 ++++- crates/engine/src/media.rs | 11 +++--- crates/ui-egui/src/headless.rs | 47 +++++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 19 ++++++++++ crates/ui-egui/src/panels/edit.rs | 9 ++++- crates/ui-egui/src/panels/grid.rs | 13 +++++++ crates/ui-egui/src/panels/right.rs | 4 ++ crates/ui-egui/src/theme.rs | 3 ++ crates/ui-egui/src/widgets.rs | 42 ++++++++++++++++++++ docs/parity.md | 5 ++- 18 files changed, 297 insertions(+), 17 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 837b3ccf..79318235 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -179,6 +179,10 @@ pub enum Op { file_size: u64, #[serde(default)] content_hash: Option, + /// Why the file can only be shown from its embedded preview (see [`Photo::preview_only`]); + /// `None` = its raw data decodes (or it isn't a raw). + #[serde(default, skip_serializing_if = "Option::is_none")] + preview_only: Option, }, /// The name shown for a colour label (`None` = its colour's name). SetLabelName { @@ -514,7 +518,7 @@ impl Catalog { let old_format = format.map(|f| std::mem::replace(&mut p.format, f)); Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format } } - Op::SetContent { id, width, height, file_size, content_hash } => { + Op::SetContent { id, width, height, file_size, content_hash, preview_only } => { let p = self.photo_mut(id)?; Op::SetContent { id, @@ -522,6 +526,7 @@ impl Catalog { height: std::mem::replace(&mut p.height, height), file_size: std::mem::replace(&mut p.file_size, file_size), content_hash: std::mem::replace(&mut p.content_hash, content_hash), + preview_only: std::mem::replace(&mut p.preview_only, preview_only), } } Op::SetFile { id, file_name, source } => { diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index f485e257..369f4041 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -200,6 +200,12 @@ pub struct Photo { /// Assisted culling scores (`None` until analysed). #[serde(default, skip_serializing_if = "Option::is_none")] pub analysis: Option, + /// A raw file whose sensor data can't be decoded yet (an unsupported raw variant): why (the + /// raw decoder's reason, e.g. "Nikon Huffman-compressed NEF …"). The photo is shown and + /// edited from the camera's embedded JPEG preview — a rendered image with the camera's + /// picture style baked in — so it is treated as a rendered (non-raw) source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview_only: Option, } /// What assisted culling measured on a photo. @@ -246,8 +252,14 @@ impl Photo { copy_name: None, import_look: None, analysis: None, + preview_only: None, } } + /// A raw file developed from its sensor data: not a rendered image, and not a raw shown from + /// its embedded preview ([`Photo::preview_only`]). + pub fn develops_raw(&self) -> bool { + self.kind == MediaKind::Raw && self.preview_only.is_none() + } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). pub fn import_defaults(&self) -> DevelopSettings { diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 778b46db..94004eb7 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -231,3 +231,36 @@ fn merge_results_are_recognised() { assert_eq!(merged_kind("hdr.jpg"), None); assert_eq!(merged_kind("panorama.jpg"), None); } + +/// Preview-only raws (an undecodable raw variant shown from its embedded JPEG): the reason is +/// stored with the photo, old catalogs without it still load, and Reload's op sets / clears it +/// with an exact inverse that survives serialisation (the journal). +#[test] +fn preview_only_survives_serde_and_set_content_undo() { + let mut c = Catalog::new(); + let a = c.alloc_photo_id(); + let mut p = Photo::new(a, Source::File { path: "/x/DSC_0001.NEF".into() }, "DSC_0001.NEF", "NEF", 6000, 4000, "2026-10-01T00:00:00"); + p.kind = MediaKind::Raw; + p.preview_only = Some("Nikon Huffman-compressed NEF".into()); + assert!(!p.develops_raw()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + // the catalog round-trips it + let back: Catalog = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap(); + assert_eq!(back.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF")); + // an older catalog (no field) loads as decodable + let mut v = serde_json::to_value(&c).unwrap(); + let photos = v["photos"].as_object_mut().unwrap(); + let ph = photos.values_mut().next().unwrap(); + ph.as_object_mut().unwrap().remove("preview_only").expect("serialised when set"); + let old: Catalog = serde_json::from_value(v).unwrap(); + assert_eq!(old.photo(a).unwrap().preview_only, None); + assert!(old.photo(a).unwrap().develops_raw()); + // Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it + let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None }; + let inv = c.apply(op).unwrap(); + assert!(c.photo(a).unwrap().develops_raw()); + let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap(); + c.apply(inv).unwrap(); + assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF")); + assert_eq!(c.photo(a).unwrap().width, 6000); +} diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 5eb17283..ddb2fdac 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -148,6 +148,24 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": out, "id": new, "original": id.0})) } +/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or +/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different +/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`]. +pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option { + let same = info.content_hash == ph.content_hash + && info.file_size == ph.file_size + && (info.width, info.height) == (ph.width, ph.height) + && info.preview_only == ph.preview_only; + (!same).then_some(Op::SetContent { + id, + width: info.width, + height: info.height, + file_size: info.file_size, + content_hash: info.content_hash, + preview_only: info.preview_only, + }) +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { @@ -164,12 +182,11 @@ fn reload(s: &mut Session, p: &Value) -> Result { let mut reloaded = Vec::new(); for ((id, _), info) in paths.into_iter().zip(probed) { let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue }; - if info.content_hash == ph.content_hash && info.file_size == ph.file_size && (info.width, info.height) == (ph.width, ph.height) { - continue; - } - ops.push(Op::SetContent { id, width: info.width, height: info.height, file_size: info.file_size, content_hash: info.content_hash }); + let Some(op) = content_op(id, ph, info.clone()) else { continue }; + ops.push(op); // virtual copies share the file for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { + ops.extend(content_op(c.id, c, info.clone())); reloaded.push(c.id); } reloaded.push(id); @@ -245,7 +262,7 @@ pub fn edit_specs() -> Vec { "Reload from Disk", [], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor) → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}", has_selection, reload ), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 7bb1e33c..f1aad8d4 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -718,7 +718,7 @@ pub fn specs() -> Vec { "Review Import", [], None, - "{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?}], duplicates, scanned} — nothing is added", + "{paths: [file or folder (recursive)]} → {candidates: [{path, name, format, kind, width, height, fileSize, captured, duplicate?: path|content, existing?, error?, previewOnly?: why a raw can only be shown from its embedded preview}], duplicates, scanned} — nothing is added", always, |s, p| { let paths = strs(p, "paths"); diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index a005e2c2..8ec96355 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -42,7 +42,15 @@ fn relink(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "only photos from files can be relinked")); } let abs = abs.to_string_lossy().to_string(); - s.commit("Relink Photo", relink_op(id, &abs))?; + // the file may differ from the one imported (another export, a raw that decodes now or not): + // its size, dimensions and preview-only state follow it + let mut ops = vec![relink_op(id, &abs)]; + if s.media.file_probe.is_some() + && let (Some(Ok(info)), Some(ph)) = (crate::import::probe_paths(s, std::slice::from_ref(&abs)).pop(), s.catalog.photo(id)) + { + ops.extend(crate::cmd::convert::content_op(id, ph, info)); + } + s.commit("Relink Photo", if ops.len() == 1 { ops.pop().expect("one op") } else { Op::Batch { ops } })?; s.media.forget(id); Ok(json!({"id": id.0, "path": abs})) } diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 73a29226..0f5ada82 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -26,6 +26,8 @@ pub fn photo_summary(p: &Photo) -> Value { "deleted": p.deleted, "copyOf": p.copy_of.map(|c| c.0), "copyName": p.copy_name, + // an undecodable raw variant, shown and edited from its embedded JPEG: why + "previewOnly": p.preview_only, }) } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 3dbe51e5..94a6ffc4 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -114,6 +114,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { as_shot_wb: None, content_hash, xmp: lightcraft_meta::embedded(bytes).xmp, + preview_only: Some(why), ..Default::default() }); } @@ -141,6 +142,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { as_shot_wb, content_hash, xmp: lightcraft_meta::embedded(bytes).xmp, + preview_only: None, }); } let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; @@ -170,6 +172,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { content_hash, embedded_lens: None, xmp: None, + preview_only: None, }) } @@ -396,6 +399,62 @@ mod tests { assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err()); } + /// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's + /// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a + /// library reopen, and Reload / Relink clear it once the file decodes (undoably). + #[test] + fn unsupported_raw_imports_as_preview_only_and_survives_reopen() { + use serde_json::json; + let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let path = files.join("DSC_0001.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + // the import review already says so + let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}"); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let p = s.catalog.photo(id).unwrap().clone(); + let why = p.preview_only.clone().expect("marked preview only"); + assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); + assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); + assert!(!p.develops_raw()); + // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults + assert_eq!(crate::media::source_info(&p), SourceInfo::default()); + assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); + assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); + // agents see it + let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap(); + assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}"); + let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap(); + assert_eq!(i["preview_only"], json!(why), "{i}"); + // a library reopen keeps it + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back + std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); + let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["reloaded"], json!([id.0]), "{r}"); + assert!(s.catalog.photo(id).unwrap().develops_raw()); + assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + // relinking to a file that decodes clears it too + let dng = files.join("DSC_0001.dng"); + std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); + s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().preview_only, None); + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn quick_jobs_show_embedded_previews_then_cached_renders() { use crate::media::QuickSource; diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index dbea7fe4..155291de 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -108,6 +108,9 @@ pub struct ImportCandidate { pub existing: Option, /// Not readable. pub error: Option, + /// A raw variant that can't be decoded yet (why): it imports as preview only. + #[serde(skip_serializing_if = "Option::is_none")] + pub preview_only: Option, } #[derive(Clone, Debug, Default, Serialize)] @@ -211,7 +214,8 @@ pub fn apply_import_defaults(s: &Session, p: &mut Photo) { crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); } let base = p.camera_defaults(); - let raw = p.kind == lightcraft_catalog::MediaKind::Raw; + // a raw shown from its embedded JPEG gets the rendered-file default, not the raw one + let raw = p.develops_raw(); let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); match preset { Some(pr) => { @@ -451,6 +455,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress Some(Ok(info)) => { (c.format, c.kind, c.width, c.height, c.file_size, c.captured) = (info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone()); + c.preview_only = info.preview_only.clone(); if let Some(h) = &info.content_hash { match seen_hash.get(h) { Some(existing) => { @@ -594,6 +599,7 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c p.as_shot_wb = info.as_shot_wb; p.content_hash = info.content_hash; p.embedded_lens = info.embedded_lens; + p.preview_only = info.preview_only.clone(); apply_import_defaults(s, &mut p); let raw = p.kind == lightcraft_catalog::MediaKind::Raw; let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming) @@ -667,6 +673,7 @@ impl Session { if let Some(info) = self.import_probes.get(&c.path) { p.as_shot_wb = info.as_shot_wb; p.embedded_lens = info.embedded_lens; + p.preview_only = info.preview_only.clone(); } p.develop = std::sync::Arc::new(p.import_defaults()); let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge()); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index a03ff2d1..4841533b 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -523,11 +523,9 @@ pub fn source_info(p: &Photo) -> SourceInfo { if matches!(p.source, Source::Demo { .. }) { return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None }; } - if p.kind == MediaKind::Raw { - SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } - } else { - SourceInfo::default() - } + // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white + // balance and the display tone curve, like any other rendered file. + if p.develops_raw() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } else { SourceInfo::default() } } impl crate::Session { @@ -751,6 +749,9 @@ pub struct ProbeInfo { pub embedded_lens: Option, /// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file. pub xmp: Option, + /// A raw variant that can't be decoded yet: why. The file is described (and will be shown and + /// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`]. + pub preview_only: Option, } pub type FileProbe = Arc Result + Send + Sync>; diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 819617a9..24c6146a 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -656,6 +656,53 @@ mod tests { assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); } + /// Issue #10: a raw shown from its embedded JPEG (an undecodable raw variant) says so — a badge + /// on its grid thumbnail, a pill on the loupe, a notice in Edit and Info — and other photos + /// show none of it. + #[test] + fn preview_only_raw_shows_badge_and_notices() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let vis = h.app.session.visible_cloned(); + let (po, other) = (vis[0], vis[1]); + let ph = h.app.session.catalog.photo(po).unwrap().clone(); + h.app + .session + .catalog + .apply(lightcraft_catalog::Op::SetContent { + id: po, + width: ph.width, + height: ph.height, + file_size: ph.file_size, + content_hash: ph.content_hash.clone(), + preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()), + }) + .unwrap(); + h.settle(SETTLE); + let ids = |h: &mut Headless, filter: &str| -> Vec { + let w = h.request("ui.widgets", json!({"filter": filter}), t); + w["result"].as_array().unwrap_or_else(|| panic!("{w}")).iter().filter_map(|x| x["id"].as_str().map(str::to_string)).collect() + }; + let badges = ids(&mut h, "badge:previewOnly:"); + assert_eq!(badges, vec![format!("badge:previewOnly:{}", po.0)], "only the preview-only photo has the badge"); + // the loupe and the Edit panel + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [po.0]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + let n = ids(&mut h, "notice:previewOnly:"); + assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}"); + // Info + h.app.ui.right = crate::state::RightPanel::Info; + h.settle(SETTLE); + assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string())); + // a regular photo: no notice + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + assert!(ids(&mut h, "notice:previewOnly:").is_empty()); + } + /// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. #[test] fn crop_keys() { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 81cee455..e0f00f37 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -350,6 +350,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } // drawn and hit-tested above the loupe and its tools: clicks on it pan navigator(app, ui, canvas, img_rect, id); + if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { + preview_only_pill(ui, canvas, why); + } resp.context_menu(|ui| { ui.menu_button("Zoom", |ui| { for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { @@ -411,6 +414,22 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); } +/// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; +/// hovering it explains (registered as `notice:previewOnly:loupe`). +fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let text = format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason)); + let g = p.layout_no_wrap(text, t.font(12.0), Color32::WHITE); + let size = vec2(g.size().x + 40.0, 26.0); + let r = Rect::from_min_size(pos2(canvas.center().x - size.x / 2.0, canvas.top() + 12.0), size); + p.rect_filled(r, 13.0, Color32::from_black_alpha(185)); + crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(15.0, 15.0)), crate::icons::Icon::Info, t.caution); + p.galley(pos2(r.left() + 29.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); + register(ui.ctx(), "notice:previewOnly:loupe", r); + ui.interact(r, egui::Id::new("preview-only-pill"), Sense::hover()).on_hover_text(crate::widgets::preview_only_explanation(reason)); +} + /// `2026-09-30T12:00:00` → `2026-09-30 12:00`. fn pretty_date(iso: &str) -> String { let d = iso.replacen('T', " ", 1); diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ece118c6..dd1872bd 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -75,7 +75,9 @@ fn rel_to_k(r: f64) -> f64 { pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); let d = app.session.develop_of(id).unwrap_or_default(); - let raw = app.session.catalog.photo(id).is_some_and(|p| p.kind == lightcraft_catalog::MediaKind::Raw); + // a raw shown from its embedded JPEG (preview only) gets the rendered-file white balance scale + let raw = app.session.catalog.photo(id).is_some_and(|p| p.develops_raw()); + let preview_only = app.session.catalog.photo(id).and_then(|p| p.preview_only.clone()); if app.ui.histogram { histogram(app, ui, id); @@ -104,6 +106,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); }); }); + if let Some(why) = &preview_only { + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 12 }).show(ui, |ui| { + crate::widgets::preview_only_notice(ui, "edit", why); + }); + } let n = app.session.selection.ids.len(); if n > 1 && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { quick_develop(app, ui, n); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 23db3687..c6d4f98f 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -432,6 +432,19 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); } + if let Some(why) = &photo.preview_only { + // a raw shown from its embedded JPEG: a small amber "Preview" pill at the image's bottom + // left, above the badge bar (always shown: it changes what editing does) + let g = p.layout_no_wrap("Preview".into(), t.semibold(9.5), Color32::WHITE); + let at = pos2(img_rect.left() + 6.0, img_rect.bottom() - 30.0 - 16.0); + let br = Rect::from_min_size(at, vec2(g.size().x + 24.0, 16.0)); + p.rect_filled(br, 8.0, Color32::from_black_alpha(170)); + paint(p, Rect::from_center_size(pos2(br.left() + 9.0, br.center().y), vec2(12.0, 12.0)), Icon::Info, t.caution); + p.galley(pos2(br.left() + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + register(ui.ctx(), format!("badge:previewOnly:{}", id.0), br); + ui.interact(br, egui::Id::new(("preview-only-badge", id.0)), Sense::hover()) + .on_hover_text(format!("Preview only — {}", crate::widgets::preview_only_explanation(why))); + } if photo.flag == Flag::Reject { p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 3748aacf..ab31da25 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -389,6 +389,10 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { padded(ui, |ui| { camera_card(ui, &p); ui.add_space(6.0); + if let Some(why) = &p.preview_only { + crate::widgets::preview_only_notice(ui, "info", why); + ui.add_space(6.0); + } if let Some(name) = &p.copy_name { let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into()); ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label)); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c2990a2b..c3f0b322 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -39,6 +39,8 @@ pub struct Tokens { pub star: Color32, pub pick: Color32, pub reject: Color32, + /// Cautionary notices (e.g. a raw shown from its embedded preview): a muted amber. + pub caution: Color32, pub mask_overlay: Color32, // metrics (points) pub top_bar_h: f32, @@ -80,6 +82,7 @@ impl Default for Tokens { star: Color32::from_rgb(0xd8, 0xd8, 0xd8), pick: Color32::from_rgb(0xf0, 0xf0, 0xf0), reject: Color32::from_rgb(0xe0, 0x4a, 0x4a), + caution: Color32::from_rgb(0xe3, 0xa8, 0x3c), mask_overlay: Color32::from_rgba_unmultiplied(0xe0, 0x20, 0x30, 110), top_bar_h: 42.0, bottom_bar_h: 48.0, diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8ce02526..f84565d1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -23,6 +23,48 @@ pub fn take_registry(ctx: &egui::Context) -> Vec<(String, Rect)> { ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::(egui::Id::new("lc-registry")).0)) } +// ------------------------------------------------------------------ preview-only raws + +/// The decoder's reason why a raw is shown from its embedded preview, shortened for the UI +/// ("Nikon Huffman-compressed NEF (no clean-room …)" → "Nikon Huffman-compressed NEF"). +pub fn preview_only_variant(reason: &str) -> &str { + reason.split(" (").next().unwrap_or(reason).trim() +} + +/// What a preview-only raw means for the user (see `Photo::preview_only`). +pub fn preview_only_explanation(reason: &str) -> String { + format!( + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, \ + which already includes the camera's picture style (e.g. Monochrome) and white balance.", + preview_only_variant(reason) + ) +} + +/// A panel notice for a raw shown from its embedded preview (Edit, Info): an amber info icon, +/// "Preview only" and the explanation. Registered as `notice:previewOnly:{key}`. +pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) { + let t = Tokens::get(ui.ctx()); + let r = egui::Frame::NONE + .fill(t.canvas) + .stroke(Stroke::new(1.0, t.caution.gamma_multiply(0.45))) + .corner_radius(6.0) + .inner_margin(egui::Margin { left: 10, right: 10, top: 8, bottom: 9 }) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (ir, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + paint(ui.painter(), ir, Icon::Info, t.caution); + ui.label(egui::RichText::new("Preview only").font(t.semibold(12.5)).color(t.text)); + }); + ui.add_space(2.0); + ui.label(egui::RichText::new(preview_only_explanation(reason)).size(11.5).color(t.text_label)); + }) + .response; + let r = r.on_hover_text(format!("Decoder: {reason}")); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Preview only: editing the camera's embedded JPEG")); + register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect); +} + // ------------------------------------------------------------------ colour helpers pub fn hex(s: &str) -> Color32 { diff --git a/docs/parity.md b/docs/parity.md index eeea2d3e..2b28322c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -57,7 +57,8 @@ Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.1%** of 500 in-sco Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on. -1. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no +1. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today: such photos are marked + `preview_only` and the UI says they are edited from the camera's embedded JPEG, issue #10); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). 2. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). @@ -109,7 +110,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | | 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-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) |