# Conflicts:
#	crates/meta/src/exif.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-14 22:32:44 -04:00
5 changed files with 104 additions and 2 deletions
+16 -1
View File
@@ -18,7 +18,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string();
let meta = Meta {
camera,
lens: m.lens_model.clone().unwrap_or_default(),
// (a camera that writes no lens name, like Nikon's DSLRs, still says what range the lens covers)
lens: m.lens_model.clone().or_else(|| m.lens_spec.and_then(lightcraft_meta::lens_spec_name)).unwrap_or_default(),
focal_mm: m.focal_length.map(|f| f as f32),
aperture: m.f_number.map(|f| f as f32),
shutter,
@@ -1054,6 +1055,20 @@ pub(crate) mod tests {
/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the
/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides
/// them; an exported `travel|Italy|Rome` comes back as itself.
#[test]
fn a_lens_without_a_name_is_shown_by_its_range() {
// a Nikon DSLR raw: no LensModel, the range from the maker note
let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() };
assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6");
let named = lightcraft_meta::Metadata {
lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()),
lens_spec: Some([24.0, 70.0, 4.0, 4.0]),
..Default::default()
};
assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S");
assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, "");
}
#[test]
fn imported_keywords_keep_their_hierarchy() {
let m = lightcraft_meta::Metadata {
+57
View File
@@ -60,6 +60,10 @@ pub fn from_tiff(tiff: &Tiff) -> Metadata {
if let Some(s) = spec.filter(|s| s.len() == 4 && s[0] > 0.0) {
m.lens_spec = Some([s[0], s[1], s[2], s[3]]);
}
// Nikon DSLRs write neither LensModel nor LensSpecification, only the same four values in their maker note
if m.lens_spec.is_none() {
m.lens_spec = nikon_lens_spec(exif, m.make.as_deref(), tiff.order);
}
m.orientation = ifd0.and_then(|i| i.u16(t::ORIENTATION)).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif);
m.rating = ifd0.and_then(|i| i.i64(t::RATING)).map(|r| r.clamp(-1, 5) as i8);
@@ -175,6 +179,25 @@ fn panasonic_jpg_from_raw_lens(tiff: &Tiff) -> Option<String> {
panasonic_lens(data, &Tiff::parse(data).ok()?)
}
/// Nikon maker note tag `0x0084` (Lens in the ExifTool Nikon tag names): shortest and longest focal length, then the
/// widest aperture at each, as rationals, like Exif LensSpecification. Zero for a lens that doesn't report itself.
const NIKON_LENS: u16 = 0x0084;
/// The lens a Nikon camera records in its maker note, when it's plausible.
fn nikon_lens_spec(exif: Option<&Ifd>, make: Option<&str>, order: lightcraft_tiff::ByteOrder) -> Option<[f64; 4]> {
let make = make?;
if !make.trim().to_ascii_uppercase().starts_with("NIKON") {
return None;
}
let note = exif?.bytes(t::MAKER_NOTE)?;
let mn = lightcraft_tiff::makernote::parse_makernote(note, 0, note.len() as u64, order, make)?;
let s = mn.ifd.f64s(NIKON_LENS)?;
let [short, long, wide, narrow] = s.as_slice() else { return None };
let focal = |f: f64| f.is_finite() && f > 0.0 && f < 10_000.0;
let aperture = |a: f64| a.is_finite() && a > 0.0 && a < 100.0;
(focal(*short) && focal(*long) && long >= short && aperture(*wide) && aperture(*narrow)).then_some([*short, *long, *wide, *narrow])
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
@@ -252,6 +275,40 @@ pub(crate) mod tests {
assert_eq!(crate::extract(&rw2).lens_model.as_deref(), Some("LUMIX S 85/F1.8"));
}
/// An Exif block from a Nikon body whose maker note records the lens (tag 0x0084) and no LensModel or
/// LensSpecification, as the D700, D750, D80 and D5300 write it.
fn nikon_exif(make: &str, lens: Option<[(u32, u32); 4]>, spec: Option<[(u32, u32); 4]>) -> Vec<u8> {
let mut note = IfdBuilder::new();
note.set(0x0001, Value::Undefined(b"0210".to_vec()));
if let Some(l) = lens {
note.set(NIKON_LENS, Value::Rational(l.to_vec()));
}
let mut blob = b"Nikon\0\x02\x10\0\0".to_vec();
blob.extend(TiffWriter::new(ByteOrder::Little, false).write(&[note]).unwrap());
let mut exif = IfdBuilder::new();
exif.set(t::MAKER_NOTE, Value::Undefined(blob));
if let Some(sp) = spec {
exif.set(t::LENS_SPECIFICATION, Value::Rational(sp.to_vec()));
}
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii(make.into()));
ifd0.set_child(t::EXIF_IFD, exif);
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
#[test]
fn nikon_maker_note_lens_fills_the_lens_spec() {
let zoom = [(18, 1), (135, 1), (35, 10), (56, 10)];
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), None)).lens_spec, Some([18.0, 135.0, 3.5, 5.6]));
// Exif's own LensSpecification wins, another make's note is not read, and a lens that reports nothing is left out
let prime = [(50, 1), (50, 1), (18, 10), (18, 10)];
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), Some(prime))).lens_spec, Some([50.0, 50.0, 1.8, 1.8]));
assert_eq!(read_exif(&nikon_exif("Canon", Some(zoom), None)).lens_spec, None);
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(0, 1), (0, 1), (0, 1), (0, 1)]), None)).lens_spec, None);
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(200, 1), (80, 1), (28, 10), (28, 10)]), None)).lens_spec, None);
assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", None, None)).lens_spec, None);
}
pub(crate) fn sample_exif(order: ByteOrder) -> Vec<u8> {
let mut exif = IfdBuilder::new();
exif.set(t::EXPOSURE_TIME, Value::Rational(vec![(1, 250)]));
+23
View File
@@ -283,6 +283,18 @@ pub fn extract(bytes: &[u8]) -> Metadata {
m
}
/// A lens name from its focal and aperture range (Exif LensSpecification, or a Nikon maker note's Lens), written the
/// way Lightroom shows a lens it has no name for: `50.0 mm f/1.8`, `18.0-135.0 mm f/3.5-5.6`.
pub fn lens_spec_name(spec: [f64; 4]) -> Option<String> {
let [short, long, wide, narrow] = spec;
if ![short, long, wide, narrow].iter().all(|v| v.is_finite() && *v > 0.0) || long < short {
return None;
}
let focal = if (long - short).abs() < 0.05 { format!("{short:.1}") } else { format!("{short:.1}-{long:.1}") };
let aperture = if (narrow - wide).abs() < 0.05 { format!("{wide:.1}") } else { format!("{wide:.1}-{narrow:.1}") };
Some(format!("{focal} mm f/{aperture}"))
}
/// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`.
pub(crate) fn parse_number(s: &str) -> Option<f64> {
let s = s.trim();
@@ -297,6 +309,17 @@ pub(crate) fn parse_number(s: &str) -> Option<f64> {
mod tests {
use super::*;
#[test]
fn lens_names_from_a_spec_read_like_lightroom() {
assert_eq!(lens_spec_name([50.0, 50.0, 1.8, 1.8]).as_deref(), Some("50.0 mm f/1.8"));
assert_eq!(lens_spec_name([18.0, 135.0, 3.5, 5.6]).as_deref(), Some("18.0-135.0 mm f/3.5-5.6"));
assert_eq!(lens_spec_name([24.0, 70.0, 2.8, 2.8]).as_deref(), Some("24.0-70.0 mm f/2.8"));
assert_eq!(lens_spec_name([70.0, 210.0, 4.0, 5.6]).as_deref(), Some("70.0-210.0 mm f/4.0-5.6"));
assert_eq!(lens_spec_name([0.0, 0.0, 0.0, 0.0]), None);
assert_eq!(lens_spec_name([200.0, 80.0, 2.8, 2.8]), None);
assert_eq!(lens_spec_name([f64::NAN, 50.0, 1.8, 1.8]), None);
}
#[test]
fn numbers() {
assert_eq!(parse_number("28/10"), Some(2.8));
+2 -1
View File
@@ -167,7 +167,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people |
| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` |
| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own |
| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map |
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset |
@@ -844,6 +844,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: 117 Nikon and Fujifilm Adobe models verified on one CC0 file each; COOLPIX 5700 / 8400 / 8800 found decoding wrongly; status unchanged (🟡) |
| 2026-10-13 | patch | LR-EXP-QUALITY: JPEG exports use Huffman tables made for each image (same pixels, 12–14 % smaller files, issue #1373); status unchanged |
| 2026-10-13 | patch | LR-BEHAV-GPU: a GPU lost while LightCraft runs (driver reset) is reported as a stop, not a failed start, and the next start renders on the CPU and says why (issue #1337); status unchanged |
| 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) |
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
+6
View File
@@ -75,6 +75,12 @@
- A photo whose Defringe amount was stored far past the sliders' 20 (in a damaged catalog or sidecar) no longer
crashes LightCraft each time it renders: the amount acts as 20.
### Lens names for Nikon DSLR raws
- Photos from Nikon DSLRs like the D700, D750, D80 or D5300 now show which lens they were taken with, for example
"24.0-70.0 mm f/2.8". Those cameras don't write a lens name, so the lens field was empty and you couldn't filter or
search by lens. LightCraft now reads the lens range Nikon keeps in its own maker note, the way Lightroom shows a
lens it doesn't have a name for. Newly imported photos show it.
### Hard-edged Heal and Clone spots
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.