LRC-LIB-SMARTCOLL: Shutter Speed rule

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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford 2026-10-09 07:02:23 -04:00
parent e54acbd176
commit 9101757bfb
No known key found for this signature in database
4 changed files with 94 additions and 14 deletions

View File

@ -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<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().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)]

View File

@ -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<f64>, 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<i64> {
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");

View File

@ -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<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().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<Metadata> {
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)

View File

@ -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"]));
}
}