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:
parent
e54acbd176
commit
9101757bfb
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue