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 <noreply@anthropic.com>

---------

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 <noreply@anthropic.com>
This commit is contained in:
eliets2
2026-11-06 10:14:19 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 eliets2 FredApps
parent 3943279105
commit 2a35db6bcf
6 changed files with 47 additions and 2 deletions
+9
View File
@@ -176,6 +176,15 @@ pub struct Meta {
pub aperture: Option<f32>,
pub shutter: String,
pub iso: Option<u32>,
/// Whether the flash fired, from Exif `Flash`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flash: Option<bool>,
/// Exposure compensation in EV, from Exif `ExposureBiasValue`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exposure_bias: Option<f32>,
/// 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,
+8 -2
View File
@@ -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,
},
}
+21
View File
@@ -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.
+5
View File
@@ -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() {
+3
View File
@@ -13,6 +13,9 @@ pub(super) fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
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(),
@@ -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),