From 2a35db6bcf78401bd05e900d89375c3c298cbb07 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Fri, 6 Nov 2026 11:14:19 +0200 Subject: [PATCH] Add flash, exposure bias and serial rules to smart albums (#3821) * Add flash, exposure bias and serial rules to smart albums Smart albums cannot filter by flash fired, exposure bias or camera serial even though the file metadata is already parsed. Add flash, exposure_bias and camera_serial to Meta, copy them in meta_of and on reload, and add flash, exposureBias and cameraSerial rule fields evaluated in Rule::matches. Test: flash_exposure_bias_and_serial_rules failed before the change and passes after. * Rules editor: Exposure Bias takes negative values Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/catalog/src/model.rs | 9 +++++++++ crates/catalog/src/rules.rs | 10 ++++++++-- crates/catalog/src/rules/tests.rs | 21 +++++++++++++++++++++ crates/engine/src/cmd/convert.rs | 5 +++++ crates/engine/src/files/probe.rs | 3 +++ crates/ui-egui/src/dialogs/rules_editor.rs | 1 + 6 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index cc28609941..69a99bd803 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -176,6 +176,15 @@ pub struct Meta { pub aperture: Option, pub shutter: String, pub iso: Option, + /// Whether the flash fired, from Exif `Flash`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flash: Option, + /// Exposure compensation in EV, from Exif `ExposureBiasValue`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exposure_bias: Option, + /// The camera body's serial number. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub serial_number: String, /// Place (sublocation, e.g. a landmark). pub location: String, pub city: String, diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 50960cc9a8..6b8c272dc0 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -105,6 +105,9 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ("aperture", "Aperture", Kind::Number), ("shutterSpeed", "Shutter Speed", Kind::Number), ("iso", "ISO Speed", Kind::Number), + ("flash", "Flash", Kind::Bool), + ("exposureBias", "Exposure Bias", Kind::Number), + ("cameraSerial", "Camera Serial Number", Kind::Text), // Location ("location", "Location", Kind::Text), ("city", "City", Kind::Text), @@ -136,7 +139,7 @@ pub const FIELD_GROUPS: &[(&str, &[&str])] = &[ ("Date", &["captureDate", "importDate", "editDate"]), ("Keywords & People", &["keywords", "keywordCount", "person", "personCount"]), ("Description", &["title", "caption", "altText", "creator", "copyright", "copyrightStatus"]), - ("Camera Info", &["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"]), + ("Camera Info", &["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso", "flash", "exposureBias", "cameraSerial"]), ("Location", &["location", "city", "state", "country", "hasGps"]), ("Size", &["longEdge", "shortEdge", "aspect", "megapixels"]), ("Develop", &["edited", "cropped", "treatment"]), @@ -1099,6 +1102,9 @@ impl Rule { "editDate" => date_op(op, p.edited.as_deref(), value), "shutterSpeed" => num_op(op, crate::parse_shutter_seconds(&m.shutter), &shutter_value(value)), "iso" => num_op(op, m.iso.map(|v| v as f64), value), + "flash" => yes(m.flash.unwrap_or(false)), + "exposureBias" => num_op(op, m.exposure_bias.map(f32_as_decimal), value), + "cameraSerial" => text(&m.serial_number), "aperture" => num_op(op, m.aperture.map(f32_as_decimal), value), "focalLength" => num_op(op, m.focal_mm.map(f32_as_decimal), value), "longEdge" | "shortEdge" => { @@ -1146,7 +1152,7 @@ impl Rule { "text" | "filePath" | "location" => COST_TEXT, "keywords" | "person" | "personCount" | "stacked" | "copyName" | "fileName" | "extension" | "format" | "title" | "caption" | "altText" | "city" | "state" | "country" | "camera" | "lens" | "creator" | "copyright" | "captureDate" | "importDate" - | "editDate" | "shutterSpeed" | "aspect" => COST_SMALL, + | "editDate" | "shutterSpeed" | "aspect" | "flash" | "cameraSerial" | "exposureBias" => COST_SMALL, _ => COST_SCALAR, }, } diff --git a/crates/catalog/src/rules/tests.rs b/crates/catalog/src/rules/tests.rs index 8d9d649b9a..e3685997a0 100644 --- a/crates/catalog/src/rules/tests.rs +++ b/crates/catalog/src/rules/tests.rs @@ -385,6 +385,27 @@ fn in_the_last_survives_hostile_counts() { set_now(None); } +/// Flash, exposure bias and camera serial number can be tested by a rule, like the other +/// camera info; an unrecorded one is empty (or zero bias is no bias). +#[test] +fn flash_exposure_bias_and_serial_rules() { + let cat = Catalog::new(); + // the values arrive with the photo's metadata, as fields on its meta, as probed from a file + let mut v = serde_json::to_value(photo(1)).unwrap(); + let meta = v.get_mut("meta").unwrap(); + meta["flash"] = json!(true); + meta["exposure_bias"] = json!(-0.67); + meta["serial_number"] = json!("SN-X2"); + let p: Photo = serde_json::from_value(v).unwrap(); + let m = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(&p, &cat); + assert!(m("flash", "is", json!(true)) && !m("flash", "is", json!(false))); + assert!(m("exposureBias", "lt", json!(0)) && m("exposureBias", "between", json!([-1.0, -0.3]))); + assert!(m("cameraSerial", "contains", json!("x2"))); + assert_eq!(field_group("flash"), Some("Camera Info")); + assert_eq!(field_group("exposureBias"), Some("Camera Info")); + assert_eq!(field_group("cameraSerial"), Some("Camera Info")); +} + /// Every rule's value is checked against its field, so a rule that can't mean anything is /// reported instead of quietly matching nothing (or everything). Each problem says which rule /// it is about: "rule 2", or "rule 2.1" inside a group. diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 24b4165278..c3dd8733f1 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -239,6 +239,11 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P m.focal_mm = m.focal_mm.or(src.focal_mm); m.aperture = m.aperture.or(src.aperture); m.iso = m.iso.or(src.iso); + m.flash = m.flash.or(src.flash); + m.exposure_bias = m.exposure_bias.or(src.exposure_bias); + if m.serial_number.is_empty() { + m.serial_number = src.serial_number.clone(); + } m.gps = m.gps.or(src.gps); // the camera's focus point: photos imported before it was read get it here if m.focus.is_none() { diff --git a/crates/engine/src/files/probe.rs b/crates/engine/src/files/probe.rs index a63446401e..481d4aabc9 100644 --- a/crates/engine/src/files/probe.rs +++ b/crates/engine/src/files/probe.rs @@ -13,6 +13,9 @@ pub(super) fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { aperture: m.f_number.map(|f| f as f32), shutter, iso: m.iso, + flash: m.flash.map(|f| f.fired), + exposure_bias: m.exposure_bias.map(|v| v as f32), + serial_number: m.serial_number.clone().unwrap_or_default(), location: m.sublocation.clone().unwrap_or_default(), city: m.city.clone().unwrap_or_default(), state: m.state.clone().unwrap_or_default(), diff --git a/crates/ui-egui/src/dialogs/rules_editor.rs b/crates/ui-egui/src/dialogs/rules_editor.rs index 67f5e4bb49..23677cfd70 100644 --- a/crates/ui-egui/src/dialogs/rules_editor.rs +++ b/crates/ui-egui/src/dialogs/rules_editor.rs @@ -80,6 +80,7 @@ fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) { "rating" => (0.0, 5.0, 0.05), "iso" => (0.0, 409_600.0, 10.0), "aperture" => (0.0, 64.0, 0.05), + "exposureBias" => (-20.0, 20.0, 0.05), "keywordCount" | "personCount" => (0.0, 1000.0, 0.05), "longEdge" | "shortEdge" => (0.0, 100_000.0, 10.0), _ => (0.0, 100_000.0, 0.5),