Files
lightcraft/crates/develop/src/lib.rs
T
Guinther KovalskiandClaude Opus 5.5 6468bfc06b M12: SAM 3 Object and Describe masks (pure Rust, Metal)
AI masks from Meta's SAM 3, run in LightCraft itself: a new
`lightcraft-segment` crate ports the Hugging Face transformers
implementation (Apache-2.0) to candle, on the GPU through Metal on macOS
(CPU elsewhere), with no C/C++ and no `unsafe`.

- Object: click to include, ⌥-click to leave a part out (SAM 3's
  interactive prompt encoder + two-way mask decoder).
- Describe: a text prompt selects every instance ("sky", "the red car";
  "car, road" merges several) via the CLIP text encoder and DETR detector.
- Both as new masks and as Add / Subtract / Intersect components; + / −
  buttons under the selected mask combine selections.
- The segmentation is stored with the mask (288² quantized logits, ~10-30
  KB), so renders, exports and the web build never need the model.
- A zoomed-in detail pass (background, ~5 s on an M4 Pro) re-segments the
  area around a selection from a sharper source: 5-10x finer edges on
  small objects.
- Per-selection Edge slider (hard ↔ soft); hovering a mask in the list
  shows it in red (the always-on overlay is now off by default, O toggles).
- The engine feature `sam` (on in the desktop app) gates the model; the
  image is encoded once per photo in the background, clicks take ~35 ms.
- Weights are not bundled (SAM License): tools/install-sam3.sh / .ps1
  download and verify facebook/sam3; docs/ai-masks.md.
- Clearer errors (missing file, missing model) and a stderr logger in the
  app (LIGHTCRAFT_LOG=info).

candle is pinned at 0.9.2: later releases make candle-core depend on
tokenizers with the Oniguruma C library. Every stage is checked against
the Python reference (tests/reference.rs, tools/sam3_reference.py): on
Metal the backbone matches to 7e-5 relative error, masks to ~5e-5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8BkS7JDqa1EMcjW6spDGA
2026-10-06 20:15:04 -03:00

171 lines
5.5 KiB
Rust

//! The non-destructive edit model of LightCraft.
//!
//! [`DevelopSettings`] is the complete description of a photo's look: pure, serializable data.
//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history
//! are operations on it. Numeric sliders are addressed by id through [`controls`].
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod controls;
pub mod presets;
pub mod segmask;
pub mod settings;
pub use controls::{CONTROLS, ControlSpec, Section, Track};
pub use presets::{Preset, SettingsGroup, apply_partial, extract_groups};
pub use segmask::SegMask;
pub use settings::*;
use serde_json::Value;
impl DevelopSettings {
/// Settings for a freshly imported raw file (Lightroom applies capture sharpening and colour
/// noise reduction by default to raw files).
pub fn for_raw(as_shot_temp: f64, as_shot_tint: f64) -> DevelopSettings {
let mut s = DevelopSettings::default();
s.wb.temp = as_shot_temp;
s.wb.tint = as_shot_tint;
s.detail.sharpen_amount = 40.0;
s.detail.nr_color = 25.0;
s
}
pub fn to_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
pub fn from_json(v: &Value) -> Result<DevelopSettings, serde_json::Error> {
serde_json::from_value(v.clone())
}
/// Merge a partial JSON object into these settings (fields not mentioned are kept).
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
DevelopSettings::from_json(&v)
}
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
pub fn hash64(&self) -> u64 {
let s = serde_json::to_string(self).unwrap_or_default();
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
/// True if nothing differs from a fresh default (ignoring white balance "as shot" values).
pub fn is_unedited(&self) -> bool {
let mut a = self.clone();
let d = DevelopSettings::default();
a.wb = d.wb;
a == d
}
pub fn section_enabled(&self, section: &str) -> bool {
!self.disabled_sections.iter().any(|s| s == section)
}
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
self.disabled_sections.retain(|s| s != section);
if !on {
self.disabled_sections.push(section.to_string());
}
}
/// Reset every control in `section` to its default.
pub fn reset_section(&mut self, section: Section) {
for c in controls::in_section(section) {
controls::set(self, c.id, c.default);
}
match section {
Section::Curve => {
let d = ToneCurve::default();
self.curve = d;
}
Section::Color => self.wb.mode = WbMode::AsShot,
_ => {}
}
}
/// Next free mask id.
pub fn next_mask_id(&self) -> u32 {
self.masks.iter().map(|m| m.id).max().map_or(1, |m| m + 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn serde_roundtrip_default_and_edited() {
let mut s = DevelopSettings::default();
controls::set(&mut s, "light.exposure", 1.25);
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Radial {
center: lightcraft_geom::Point::new(0.5, 0.5),
rx: 0.2,
ry: 0.1,
angle: 10.0,
feather: 50.0,
invert: false,
},
}],
..Default::default()
});
let v = s.to_json();
let back = DevelopSettings::from_json(&v).unwrap();
assert_eq!(back, s);
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
}
#[test]
fn missing_fields_take_defaults_and_unknown_ignored() {
let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap();
assert_eq!(s.light.exposure, 0.5);
assert_eq!(s.grading.blending, 50.0);
}
#[test]
fn merge_partial() {
let s = DevelopSettings::default();
let m = s.merged(&json!({"light": {"contrast": 20}, "effects": {"clarity": 10}})).unwrap();
assert_eq!(m.light.contrast, 20.0);
assert_eq!(m.effects.clarity, 10.0);
assert_eq!(m.light.exposure, 0.0);
}
#[test]
fn unedited_and_reset() {
let mut s = DevelopSettings::default();
s.wb.temp = 5000.0;
assert!(s.is_unedited());
s.light.exposure = 1.0;
s.light.shadows = 30.0;
assert!(!s.is_unedited());
s.reset_section(Section::Light);
assert!(s.is_unedited());
}
#[test]
fn section_toggles() {
let mut s = DevelopSettings::default();
assert!(s.section_enabled("effects"));
s.set_section_enabled("effects", false);
s.set_section_enabled("effects", false);
assert!(!s.section_enabled("effects"));
assert_eq!(s.disabled_sections.len(), 1);
s.set_section_enabled("effects", true);
assert!(s.section_enabled("effects"));
}
}