Merge pull request #40 from storytold/fix/issue-10-preview-only

Mark raws shown from their embedded preview as "preview only" (refs #10)
This commit is contained in:
Brandon Thomas 2026-10-04 03:05:18 -04:00 committed by GitHub
commit b80bec6e3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 297 additions and 17 deletions

View File

@ -179,6 +179,10 @@ pub enum Op {
file_size: u64, file_size: u64,
#[serde(default)] #[serde(default)]
content_hash: Option<String>, content_hash: Option<String>,
/// 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<String>,
}, },
/// The name shown for a colour label (`None` = its colour's name). /// The name shown for a colour label (`None` = its colour's name).
SetLabelName { SetLabelName {
@ -514,7 +518,7 @@ impl Catalog {
let old_format = format.map(|f| std::mem::replace(&mut p.format, f)); 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::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)?; let p = self.photo_mut(id)?;
Op::SetContent { Op::SetContent {
id, id,
@ -522,6 +526,7 @@ impl Catalog {
height: std::mem::replace(&mut p.height, height), height: std::mem::replace(&mut p.height, height),
file_size: std::mem::replace(&mut p.file_size, file_size), file_size: std::mem::replace(&mut p.file_size, file_size),
content_hash: std::mem::replace(&mut p.content_hash, content_hash), 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 } => { Op::SetFile { id, file_name, source } => {

View File

@ -200,6 +200,12 @@ pub struct Photo {
/// Assisted culling scores (`None` until analysed). /// Assisted culling scores (`None` until analysed).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub analysis: Option<Analysis>, pub analysis: Option<Analysis>,
/// 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<String>,
} }
/// What assisted culling measured on a photo. /// What assisted culling measured on a photo.
@ -246,8 +252,14 @@ impl Photo {
copy_name: None, copy_name: None,
import_look: None, import_look: None,
analysis: 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 /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]). /// default preset applied on top of them ([`Photo::import_look`]).
pub fn import_defaults(&self) -> DevelopSettings { pub fn import_defaults(&self) -> DevelopSettings {

View File

@ -231,3 +231,36 @@ fn merge_results_are_recognised() {
assert_eq!(merged_kind("hdr.jpg"), None); assert_eq!(merged_kind("hdr.jpg"), None);
assert_eq!(merged_kind("panorama.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);
}

View File

@ -148,6 +148,24 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": out, "id": new, "original": id.0})) 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<Op> {
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, /// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> { fn reload(s: &mut Session, p: &Value) -> Result<Value> {
@ -164,12 +182,11 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let mut reloaded = Vec::new(); let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) { for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue }; 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) { let Some(op) = content_op(id, ph, info.clone()) else { continue };
continue; ops.push(op);
}
ops.push(Op::SetContent { id, width: info.width, height: info.height, file_size: info.file_size, content_hash: info.content_hash });
// virtual copies share the file // virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { 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(c.id);
} }
reloaded.push(id); reloaded.push(id);
@ -245,7 +262,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
"Reload from Disk", "Reload from Disk",
[], [],
None, 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, has_selection,
reload reload
), ),

View File

@ -718,7 +718,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Review Import", "Review Import",
[], [],
None, 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, always,
|s, p| { |s, p| {
let paths = strs(p, "paths"); let paths = strs(p, "paths");

View File

@ -42,7 +42,15 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "only photos from files can be relinked")); return Err(bad(C, "only photos from files can be relinked"));
} }
let abs = abs.to_string_lossy().to_string(); 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); s.media.forget(id);
Ok(json!({"id": id.0, "path": abs})) Ok(json!({"id": id.0, "path": abs}))
} }

View File

@ -26,6 +26,8 @@ pub fn photo_summary(p: &Photo) -> Value {
"deleted": p.deleted, "deleted": p.deleted,
"copyOf": p.copy_of.map(|c| c.0), "copyOf": p.copy_of.map(|c| c.0),
"copyName": p.copy_name, "copyName": p.copy_name,
// an undecodable raw variant, shown and edited from its embedded JPEG: why
"previewOnly": p.preview_only,
}) })
} }

View File

@ -114,6 +114,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
as_shot_wb: None, as_shot_wb: None,
content_hash, content_hash,
xmp: lightcraft_meta::embedded(bytes).xmp, xmp: lightcraft_meta::embedded(bytes).xmp,
preview_only: Some(why),
..Default::default() ..Default::default()
}); });
} }
@ -141,6 +142,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
as_shot_wb, as_shot_wb,
content_hash, content_hash,
xmp: lightcraft_meta::embedded(bytes).xmp, xmp: lightcraft_meta::embedded(bytes).xmp,
preview_only: None,
}); });
} }
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
@ -170,6 +172,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
content_hash, content_hash,
embedded_lens: None, embedded_lens: None,
xmp: 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()); 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] #[test]
fn quick_jobs_show_embedded_previews_then_cached_renders() { fn quick_jobs_show_embedded_previews_then_cached_renders() {
use crate::media::QuickSource; use crate::media::QuickSource;

View File

@ -108,6 +108,9 @@ pub struct ImportCandidate {
pub existing: Option<u64>, pub existing: Option<u64>,
/// Not readable. /// Not readable.
pub error: Option<String>, pub error: Option<String>,
/// 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<String>,
} }
#[derive(Clone, Debug, Default, Serialize)] #[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); crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields);
} }
let base = p.camera_defaults(); 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)); let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id));
match preset { match preset {
Some(pr) => { Some(pr) => {
@ -451,6 +455,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
Some(Ok(info)) => { Some(Ok(info)) => {
(c.format, c.kind, c.width, c.height, c.file_size, c.captured) = (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()); (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 { if let Some(h) = &info.content_hash {
match seen_hash.get(h) { match seen_hash.get(h) {
Some(existing) => { 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.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash; p.content_hash = info.content_hash;
p.embedded_lens = info.embedded_lens; p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
apply_import_defaults(s, &mut p); apply_import_defaults(s, &mut p);
let raw = p.kind == lightcraft_catalog::MediaKind::Raw; let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming) 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) { if let Some(info) = self.import_probes.get(&c.path) {
p.as_shot_wb = info.as_shot_wb; p.as_shot_wb = info.as_shot_wb;
p.embedded_lens = info.embedded_lens; p.embedded_lens = info.embedded_lens;
p.preview_only = info.preview_only.clone();
} }
p.develop = std::sync::Arc::new(p.import_defaults()); p.develop = std::sync::Arc::new(p.import_defaults());
let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge()); let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge());

View File

@ -523,11 +523,9 @@ pub fn source_info(p: &Photo) -> SourceInfo {
if matches!(p.source, Source::Demo { .. }) { if matches!(p.source, Source::Demo { .. }) {
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None }; return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
} }
if p.kind == MediaKind::Raw { // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } // balance and the display tone curve, like any other rendered file.
} else { if p.develops_raw() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } else { SourceInfo::default() }
SourceInfo::default()
}
} }
impl crate::Session { impl crate::Session {
@ -751,6 +749,9 @@ pub struct ProbeInfo {
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>, pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
/// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file. /// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
pub xmp: Option<String>, pub xmp: Option<String>,
/// 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<String>,
} }
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>; pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;

View File

@ -656,6 +656,53 @@ mod tests {
assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); 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<String> {
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. /// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect.
#[test] #[test]
fn crop_keys() { fn crop_keys() {

View File

@ -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 // drawn and hit-tested above the loupe and its tools: clicks on it pan
navigator(app, ui, canvas, img_rect, id); 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| { resp.context_menu(|ui| {
ui.menu_button("Zoom", |ui| { ui.menu_button("Zoom", |ui| {
for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { 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))); 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`. /// `2026-09-30T12:00:00` → `2026-09-30 12:00`.
fn pretty_date(iso: &str) -> String { fn pretty_date(iso: &str) -> String {
let d = iso.replacen('T', " ", 1); let d = iso.replacen('T', " ", 1);

View File

@ -75,7 +75,9 @@ fn rel_to_k(r: f64) -> f64 {
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let d = app.session.develop_of(id).unwrap_or_default(); 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 { if app.ui.histogram {
histogram(app, ui, id); 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(); let n = app.session.selection.ids.len();
if n > 1 && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { if n > 1 && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) {
quick_develop(app, ui, n); quick_develop(app, ui, n);

View File

@ -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.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); 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 { if photo.flag == Flag::Reject {
p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110));
} }

View File

@ -389,6 +389,10 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
padded(ui, |ui| { padded(ui, |ui| {
camera_card(ui, &p); camera_card(ui, &p);
ui.add_space(6.0); 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 { 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()); 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)); ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label));

View File

@ -39,6 +39,8 @@ pub struct Tokens {
pub star: Color32, pub star: Color32,
pub pick: Color32, pub pick: Color32,
pub reject: 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, pub mask_overlay: Color32,
// metrics (points) // metrics (points)
pub top_bar_h: f32, pub top_bar_h: f32,
@ -80,6 +82,7 @@ impl Default for Tokens {
star: Color32::from_rgb(0xd8, 0xd8, 0xd8), star: Color32::from_rgb(0xd8, 0xd8, 0xd8),
pick: Color32::from_rgb(0xf0, 0xf0, 0xf0), pick: Color32::from_rgb(0xf0, 0xf0, 0xf0),
reject: Color32::from_rgb(0xe0, 0x4a, 0x4a), reject: Color32::from_rgb(0xe0, 0x4a, 0x4a),
caution: Color32::from_rgb(0xe3, 0xa8, 0x3c),
mask_overlay: Color32::from_rgba_unmultiplied(0xe0, 0x20, 0x30, 110), mask_overlay: Color32::from_rgba_unmultiplied(0xe0, 0x20, 0x30, 110),
top_bar_h: 42.0, top_bar_h: 42.0,
bottom_bar_h: 48.0, bottom_bar_h: 48.0,

View File

@ -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::<Registry>(egui::Id::new("lc-registry")).0)) ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::<Registry>(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 // ------------------------------------------------------------------ colour helpers
pub fn hex(s: &str) -> Color32 { pub fn hex(s: &str) -> Color32 {

View File

@ -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. 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). 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). 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-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-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-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-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) | | 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) |