From 9101757bfb7f2ebcf4f85f3fc9d0b7f03dbb26de Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:02:23 -0400 Subject: [PATCH] LRC-LIB-SMARTCOLL: Shutter Speed rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camera Info gains Shutter Speed, compared in seconds and written as the camera shows it: "≤ 1/60" finds faster exposures, "between 1/1000 and 1/125" works, and a value that isn't a time matches nothing. The editor takes it as text (1/250, 0.5, 2"). The shutter parser moves from engine export into the catalog (parse_shutter_seconds) so export and the rule share it. A new rules-editor test checks that every field × operator default value makes a valid rule. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/model.rs | 11 ++++++ crates/catalog/src/rules.rs | 48 ++++++++++++++++++++++- crates/engine/src/export.rs | 12 +----- crates/ui-egui/src/panels/rules_editor.rs | 37 ++++++++++++++++- 4 files changed, 94 insertions(+), 14 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index d5a64922..584a35ef 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -152,6 +152,17 @@ impl CopyrightStatus { } } +/// A shutter speed as cameras write it (`1/250`, `0.5`, `2"`, `30s`) in seconds; `None` for +/// anything that isn't a positive, finite time. +pub fn parse_shutter_seconds(s: &str) -> Option { + let s = s.trim().trim_end_matches(['s', '"']).trim(); + match s.split_once('/') { + Some((n, d)) => Some(n.trim().parse::().ok()? / d.trim().parse::().ok()?), + None => s.parse().ok(), + } + .filter(|v: &f64| v.is_finite() && *v > 0.0) +} + /// Descriptive + capture metadata. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index cacc9c2d..bc318a3f 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -88,6 +88,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ("lens", "Lens", Kind::Text), ("focalLength", "Focal Length", Kind::Number), ("aperture", "Aperture", Kind::Number), + ("shutterSpeed", "Shutter Speed", Kind::Number), ("iso", "ISO Speed", Kind::Number), // Location ("location", "Location", Kind::Text), @@ -112,7 +113,7 @@ pub const FIELD_GROUPS: &[(&str, &[&str])] = &[ ("Date", &["captureDate", "importDate", "editDate"]), ("Keywords & People", &["keywords", "keywordCount", "person", "personCount"]), ("Description", &["title", "caption", "creator", "copyright", "copyrightStatus"]), - ("Camera Info", &["camera", "lens", "focalLength", "aperture", "iso"]), + ("Camera Info", &["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"]), ("Location", &["location", "hasGps"]), ("Size", &["megapixels"]), ("Develop", &["edited"]), @@ -259,6 +260,19 @@ fn num_op(op: &str, have: Option, value: &Value) -> bool { } } +/// A shutter-speed rule's value in seconds: camera notation (`1/250`) or a number; an operand +/// that isn't a time becomes `null`, which matches nothing. +fn shutter_value(value: &Value) -> Value { + let secs = |v: &Value| match v { + Value::String(s) => crate::parse_shutter_seconds(s).map_or(Value::Null, Value::from), + other => number(other).filter(|n| n.is_finite() && *n > 0.0).map_or(Value::Null, Value::from), + }; + match value { + Value::Array(a) => Value::Array(a.iter().map(secs).collect()), + v => secs(v), + } +} + /// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days. fn last_secs(value: &Value) -> Option { let (n, unit) = match value { @@ -387,6 +401,7 @@ impl Rule { "captureDate" => date_op(op, p.captured.as_deref(), value), "importDate" => date_op(op, Some(&p.imported), value), "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), "aperture" => num_op(op, m.aperture.map(|v| v as f64), value), "focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value), @@ -619,6 +634,35 @@ mod tests { assert_eq!(field_group("person"), Some("Keywords & People")); } + /// Shutter Speed compares exposure times in seconds, written as the camera shows them: a + /// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125". + #[test] + fn shutter_speed_rules() { + let cat = Catalog::new(); + let mut p = photo(1); + let m = |p: &Photo, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded"); + p.meta.shutter = "1/250".into(); + assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60"))); + assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01))); + assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing"); + p.meta.shutter = "2\"".into(); + assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2))); + assert_eq!(field_group("shutterSpeed"), Some("Camera Info")); + } + + #[test] + fn shutter_seconds_parses_camera_notation() { + for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] { + assert_eq!(crate::parse_shutter_seconds(s), want, "{s}"); + } + for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] { + assert_eq!(crate::parse_shutter_seconds(s), None, "{s}"); + } + } + /// The field menu shows every rule field once: at the top level or in exactly one group, /// and [`FIELDS`] lists them in menu order. #[test] @@ -644,7 +688,7 @@ mod tests { } }; same(&["fileName", "filePath", "kind", "format"], "File"); - same(&["camera", "lens", "focalLength", "aperture", "iso"], "Camera Info"); + same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info"); same(&["captureDate", "importDate", "editDate"], "Date"); same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); same(&["title", "caption", "creator", "copyright", "copyrightStatus"], "Description"); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 33366285..b9a531eb 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1231,16 +1231,6 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) r.map_err(|e| e.to_string()) } -/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds. -fn parse_shutter(s: &str) -> Option { - let s = s.trim().trim_end_matches(['s', '"']).trim(); - match s.split_once('/') { - Some((n, d)) => Some(n.trim().parse::().ok()? / d.trim().parse::().ok()?), - None => s.parse().ok(), - } - .filter(|v: &f64| v.is_finite() && *v > 0.0) -} - /// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option { let m = &photo.meta; @@ -1283,7 +1273,7 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> out.lens_model = text(&m.lens); out.focal_length = m.focal_mm.map(f64::from); out.f_number = m.aperture.map(f64::from); - out.exposure_time = parse_shutter(&m.shutter); + out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter); out.iso = m.iso; } Some(out) diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index c3344983..ca01e6f8 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -15,6 +15,8 @@ pub fn default_value(field: &str, op: &str) -> Value { (_, "isEmpty" | "isNotEmpty") => Value::Null, (Some(Kind::Date), "inLast" | "notInLast") => json!({"n": 30, "unit": "days"}), (Some(Kind::Date), "between") => json!(["", ""]), + (Some(Kind::Number), "between") if field == "shutterSpeed" => json!(["1/1000", "1/125"]), + (Some(Kind::Number), _) if field == "shutterSpeed" => json!("1/250"), (Some(Kind::Number), "between") => json!([0, 0]), (Some(Kind::Number), _) => json!(if field == "rating" { 3 } else { 0 }), (Some(Kind::Choice(c)), _) => json!(c.first().copied().unwrap_or("")), @@ -97,7 +99,11 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & *v = default_value(field, op); } let Value::Array(a) = v else { return }; - if k == Kind::Number { + if field == "shutterSpeed" { + text_value(ui, &mut a[0], 60.0, "1/1000", &format!("{salt}-a")); + ui.label(crate::i18n::tr("and")); + text_value(ui, &mut a[1], 60.0, "1/125", &format!("{salt}-b")); + } else if k == Kind::Number { number_value(ui, &mut a[0], field); ui.label(crate::i18n::tr("and")); number_value(ui, &mut a[1], field); @@ -107,6 +113,8 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & text_value(ui, &mut a[1], 86.0, "2026-12", &format!("{salt}-b")); } } + // camera notation: 1/250, 0.5, 2" + (Some(Kind::Number), _) if field == "shutterSpeed" => text_value(ui, v, 70.0, "1/250", salt), (Some(Kind::Number), _) => number_value(ui, v, field), (Some(Kind::Choice(c)), _) => { let cur = v.as_str().unwrap_or("").to_string(); @@ -263,3 +271,30 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { }); } } + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_catalog::rules::FIELDS; + use lightcraft_catalog::{Catalog, Photo, PhotoId, Source}; + + /// Every field × operator starts with a value the rule accepts; a new shutter-speed rule starts + /// at a real time in camera notation. + #[test] + fn default_values_make_valid_rules() { + let cat = Catalog::new(); + let p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "JPEG", 10, 10, "2026-10-01T00:00:00"); + for (field, _, kind) in FIELDS { + for (op, _) in ops_for(*kind) { + let rs = RuleSet { + mode: Match::All, + rules: vec![Rule::Field { field: field.to_string(), op: op.to_string(), value: default_value(field, op) }], + }; + assert!(rs.problems().is_empty(), "{field} {op}: {:?}", rs.problems()); + let _ = rs.matches(&p, &cat); + } + } + assert_eq!(default_value("shutterSpeed", "gte"), json!("1/250")); + assert_eq!(default_value("shutterSpeed", "between"), json!(["1/1000", "1/125"])); + } +}