mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-03 06:59:53 -06:00
Merge PR #1590
# Conflicts: # crates/meta/src/exif.rs # docs/parity-checklist.md # docs/whats-new.md
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)]));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user