Browse / github.com/storytold/lightcraft / patch
LR-MASK-OBJECTS: Settings can remove the SAM 3 model, with its size on the button
LR-MASK-OBJECTS: Settings can remove the SAM 3 model, with its size on the button The SAM 3 model behind Object and Describe masks is a download of about 3.4 GB, and the only way to get that room back was to find the model folder and delete the files by hand. Face and AI Denoise models already have a Remove (`faces.models.remove`, `denoise.models.remove`, and a button in Settings); this model had none, in the app or for scripts. Now `segment.model.remove {id: "sam3"}` removes the model's files and Settings > AI Denoise > AI masks has a "Remove SAM 3 (size)" button; masks already made stay, and the next new selection offers the download again. ## Cause `segment.model` had
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- sm18lr88 (imported from pull request #3091, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +262 −6 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
94c906343c75ac1dc75ae59f
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/masks.rs crates/engine/src/segment.rs crates/engine/src/tests/segment.rs crates/ui-egui/src/dialogs/settings.rs crates/ui-egui/src/panels/denoise.rs crates/ui-egui/src/tests.rs crates/ui-egui/src/tests/sam_remove.rs docs/ai-masks.md docs/parity-checklist.md docs/whats-new.md
@@ -551,7 +551,7 @@ pub fn specs() -> Vec<CommandSpec> { "AI Mask Model Status", [], None,- "{} — whether this build has AI masks, whether the SAM 3 model is installed (and where), loaded, busy, and the download's progress → {available, installed, dir, loaded, busy, analyzing, sizeBytes, license, licenseUrl, mirrors, download: {running, done, total, file, error, finished}}",+ "{} — whether this build has AI masks, whether the SAM 3 model is installed (and where), loaded, busy, and the download's progress → {available, installed, dir, loaded, busy, analyzing, sizeBytes, diskBytes (what the model's files and partial downloads take now), license, licenseUrl, mirrors, download: {running, done, total, file, error, finished}}", super::always, |s, _| { let g = &s.segmenter;@@ -564,6 +564,7 @@ pub fn specs() -> Vec<CommandSpec> { "busy": g.busy(), "analyzing": g.analyzing(), "sizeBytes": crate::segment::MODEL_BYTES,+ "diskBytes": g.disk_bytes(), "license": crate::segment::LICENSE_NAME, "licenseUrl": crate::segment::LICENSE_URL, "mirrors": g.mirrors().len(),@@ -604,6 +605,22 @@ pub fn specs() -> Vec<CommandSpec> { super::always, |s, _| Ok(json!({"cancelled": s.segmenter.cancel_download()})) ),+ cmd!(+ query "segment.model.remove",+ "Remove AI Mask Model",+ [],+ None,+ "{id: \"sam3\"} — delete the SAM 3 model's files and partial downloads from the model folder, nothing else; refused while it downloads or computes a mask; masks already made stay, and the next Object or Describe selection offers the download again → {removed: [paths], bytes}",+ super::always,+ |s, p| {+ let c = "segment.model.remove";+ if p.get("id").and_then(Value::as_str) != Some("sam3") {+ return Err(bad(c, "needs `id`: \"sam3\" (the only mask model); nothing was removed"));+ }+ let (removed, bytes) = s.segmenter.remove_files().map_err(|e| bad(c, e))?;+ Ok(json!({"removed": removed, "bytes": bytes}))+ }+ ), cmd!( "mask.refine", "Refine Mask Edges",@@ -35,6 +35,9 @@ pub const MODEL_BYTES: u64 = 3_439_938_512; /// The model's licence (not LightCraft's): shown before downloading. pub const LICENSE_NAME: &str = "SAM License (Meta)"; pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE";+/// The files a download puts in the model folder (`facebook/sam3`; a download in progress is `<name>.part`). Named here+/// so builds without `sam` can still report and remove a downloaded model.+pub const MODEL_FILES: [&str; 3] = ["model.safetensors", "vocab.json", "merges.txt"]; /// How errors about a missing model start (frontends decide on [`crate::ErrorKind::ModelMissing`], not on this text). pub const NOT_INSTALLED: &str = "The SAM 3 model is not installed"; /// Longest a waiting command (CLI, MCP) waits for the model (loading on a CPU can take minutes).@@ -176,6 +179,54 @@ impl Segmenter { false } + /// Bytes the model's files and partial downloads take in the model folder (0 when there is no folder or nothing in it).+ pub fn disk_bytes(&self) -> u64 {+ let Some(dir) = &self.dir else { return 0 };+ MODEL_FILES+ .iter()+ .flat_map(|n| [dir.join(n), dir.join(format!("{n}.part"))])+ .filter_map(|p| std::fs::symlink_metadata(p).ok())+ // (the scope of remove_files: a file or a link, counted by its own size and never followed; a folder is left alone)+ .filter(|m| !m.is_dir())+ .fold(0u64, |sum, m| sum.saturating_add(m.len()))+ }++ /// Delete the model's files and partial downloads from the model folder, nothing else (`segment.model.remove`):+ /// `(paths removed, bytes freed)`. Refused while the model downloads or computes a mask. A file that cannot be+ /// deleted (another program holds it open) is an error that names it; the others are gone by then.+ pub fn remove_files(&self) -> Result<(Vec<String>, u64), String> {+ if self.busy() || self.download_status().running {+ return Err(+ "the SAM 3 model is in use (a download or an AI mask is running); wait for it to finish (or stop the download), then remove it"+ .into(),+ );+ }+ let dir = self.dir.as_ref().ok_or("no folder is set for the SAM 3 model")?;+ let (mut removed, mut bytes, mut failed) = (Vec::new(), 0u64, Vec::new());+ for name in MODEL_FILES.iter().flat_map(|n| [n.to_string(), format!("{n}.part")]) {+ let path = dir.join(&name);+ // (a link is removed itself, never what it points to; a folder of that name is left alone)+ let meta = match std::fs::symlink_metadata(&path) {+ Ok(meta) if meta.is_dir() => continue,+ Ok(meta) => meta,+ // (not there: nothing to remove; any other error means we cannot tell, so it is reported)+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,+ Err(e) => {+ failed.push(format!("{}: {e}", path.display()));+ continue;+ }+ };+ match std::fs::remove_file(&path) {+ Ok(()) => {+ removed.push(path.display().to_string());+ bytes = bytes.saturating_add(meta.len());+ }+ Err(e) => failed.push(format!("{}: {e}", path.display())),+ }+ }+ if failed.is_empty() { Ok((removed, bytes)) } else { Err(format!("could not remove {}", failed.join("; "))) }+ }+ /// Whether requests are queued or running (loading, analyzing the photo, a click…). pub fn busy(&self) -> bool { #[cfg(feature = "sam")]@@ -42,6 +42,91 @@ fn settle(s: &mut Session, limit: Duration) -> Vec<String> { } } +/// `segment.model.remove` deletes the model's files and partial downloads, nothing else, and says how much it freed.+#[test]+fn removing_the_model_deletes_its_files_and_nothing_else() {+ let mut s = Session::with_demo();+ // no folder, no id, another id: nothing to remove, said plainly+ let e = s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap_err().to_string();+ assert!(e.contains("no folder"), "{e}");+ let dir = damaged_model("remove");+ std::fs::write(dir.join("model.safetensors.part"), b"half").unwrap();+ std::fs::write(dir.join("notes.txt"), b"mine").unwrap();+ s.segmenter.dir = Some(dir.clone());+ let size = |names: &[&str]| names.iter().map(|n| std::fs::metadata(dir.join(n)).unwrap().len()).sum::<u64>();+ let mine = size(&["model.safetensors", "vocab.json", "merges.txt", "model.safetensors.part"]);+ assert_eq!(s.execute("segment.model.status", &json!({})).unwrap()["diskBytes"], mine);+ for p in [json!({}), json!({"id": "x"}), json!({"id": ["sam3"]})] {+ let e = s.execute("segment.model.remove", &p).unwrap_err().to_string();+ assert!(e.contains("sam3") && e.contains("nothing was removed"), "{p}: {e}");+ }+ assert!(dir.join("model.safetensors").is_file(), "a refused call removes nothing");+ let r = s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap();+ assert_eq!(r["removed"].as_array().unwrap().len(), 4, "{r}");+ assert_eq!(r["bytes"], mine);+ assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "only notes.txt is left");+ assert_eq!(std::fs::read(dir.join("notes.txt")).unwrap(), b"mine");+ assert_eq!(s.execute("segment.model.status", &json!({})).unwrap()["diskBytes"], 0);+ // again: nothing there, nothing to say but so+ assert_eq!(s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap()["bytes"], 0);+ let _ = std::fs::remove_dir_all(dir);+}++/// A model file that is a symbolic link counts by the link's own size (never followed) and is removed as a link, so the size+/// shown and what Remove deletes agree; what the link points to survives.+#[test]+fn a_linked_model_file_is_counted_and_removed_as_a_link() {+ let dir = tmp("remove-link");+ let outside = tmp("remove-link-target");+ std::fs::create_dir_all(&dir).unwrap();+ std::fs::create_dir_all(&outside).unwrap();+ let target = outside.join("weights.bin");+ std::fs::write(&target, vec![7u8; 4096]).unwrap();+ let link = dir.join("model.safetensors");+ #[cfg(unix)]+ let made = std::os::unix::fs::symlink(&target, &link);+ #[cfg(windows)]+ let made = std::os::windows::fs::symlink_file(&target, &link);+ #[cfg(not(any(unix, windows)))]+ let made: std::io::Result<()> = Err(std::io::Error::other("no symbolic links on this platform"));+ if let Err(e) = made {+ lightcraft_testkit::skip!(System, "cannot create a symbolic link here: {e}");+ let _ = std::fs::remove_dir_all(&dir);+ let _ = std::fs::remove_dir_all(&outside);+ return;+ }+ std::fs::write(dir.join("vocab.json"), b"{}").unwrap();+ let link_len = std::fs::symlink_metadata(&link).unwrap().len();+ let mut s = Session::with_demo();+ s.segmenter.dir = Some(dir.clone());+ assert_eq!(s.segmenter.disk_bytes(), link_len + 2, "the link counts by its own size");+ #[cfg(unix)]+ assert!(s.segmenter.disk_bytes() > 2, "a link is never zero bytes on unix");+ let r = s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap();+ assert_eq!(r["removed"].as_array().unwrap().len(), 2, "{r}");+ assert_eq!(r["bytes"], link_len + 2);+ assert!(std::fs::symlink_metadata(&link).is_err(), "the link is gone");+ assert_eq!(std::fs::read(&target).unwrap().len(), 4096, "what it pointed to is untouched");+ let _ = std::fs::remove_dir_all(&dir);+ let _ = std::fs::remove_dir_all(&outside);+}++/// A model folder that cannot be inspected (any error but "not found") is an error naming it, not a silent success.+#[test]+fn an_unreadable_model_folder_is_an_error_that_names_it() {+ // a NUL in the path is rejected by the OS with an error that is not NotFound, on every platform+ let dir = std::env::temp_dir().join(format!("lc-sam3-bad\0dir-{}", std::process::id()));+ let kind = std::fs::symlink_metadata(dir.join("model.safetensors")).unwrap_err().kind();+ assert_ne!(kind, std::io::ErrorKind::NotFound, "the trigger must not be a missing file");+ let mut s = Session::with_demo();+ s.segmenter.dir = Some(dir.clone());+ let e = s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap_err().to_string();+ assert!(e.contains("could not remove") && e.contains("model.safetensors"), "{e}");+ // a folder that simply is not there is still nothing to remove, not an error+ s.segmenter.dir = Some(tmp("never-created"));+ assert_eq!(s.execute("segment.model.remove", &json!({"id": "sam3"})).unwrap()["bytes"], 0);+}+ #[test] fn status_and_model_commands_without_the_model() { let mut s = Session::with_demo();@@ -57,6 +57,32 @@ pub const TABS: &[(&str, &str)] = &[ ("denoise", "AI Denoise"), ]; +/// Settings ▸ AI Denoise, below its models: the model behind Object and Describe masks (SAM 3) takes up to 3.4 GB, and its+/// Remove gives the room back (`segment.model.remove`, as a face or denoise model's Remove does). Nothing shows while+/// no file of it is on disk.+fn ai_mask_model(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {+ let st = app.session.execute("segment.model.status", &json!({})).unwrap_or(Value::Null);+ let bytes = st["diskBytes"].as_u64().unwrap_or(0);+ if st["available"] != true || bytes == 0 {+ return;+ }+ heading(ui, t, crate::i18n::tr("AI masks"));+ hint(+ ui,+ t,+ crate::i18n::tr(+ "Object and Describe masks use the SAM 3 model. Removing it frees the room it takes: masks already made stay, and the next new selection offers the download again.",+ ),+ );+ let in_use = st["busy"] == true || st["download"]["running"] == true;+ let label = crate::i18n::tr_format!("Remove SAM 3 ({size})", size = crate::panels::denoise::mb(Some(bytes)));+ let r = ui.add_enabled(!in_use, egui::Button::new(label));+ register(ui, "settings:sam3Remove", r.rect);+ if r.clicked() {+ let _ = app.run("segment.model.remove", json!({"id": "sam3"}));+ }+}+ /// Thumbnail cache sizes offered (MB). const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192]; @@ -72,7 +98,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &str) { "interface" => interface_tab(app, ui, &t), "slideshow" => slideshow_tab(app, ui, &t, true), "faces" => crate::panels::faces::settings_tab(app, ui, &t),- "denoise" => crate::panels::denoise::settings_tab(app, ui, &t),+ "denoise" => {+ crate::panels::denoise::settings_tab(app, ui, &t);+ ai_mask_model(app, ui, &t);+ } _ => general_tab(app, ui, &t), } }@@ -633,7 +633,7 @@ fn pace_for(focused: bool, minimized: bool, worked: f64, moved: f64, watching: b } } -pub(super) fn mb(bytes: Option<u64>) -> String {+pub(crate) fn mb(bytes: Option<u64>) -> String { match bytes { Some(b) if b >= 10_000_000 => tr_format!("{} MB", (b as f64 / 1e6).round() as u64), Some(b) if b >= 1_000_000 => tr_format!("{:.1} MB", b as f64 / 1e6),@@ -66,6 +66,7 @@ mod rating; mod recheck; mod rename_person; mod rules_editor;+mod sam_remove; mod sam_setup; mod scroll; mod slider_reset;new file mode 100644@@ -0,0 +1,65 @@+//! Settings ▸ AI Denoise ▸ AI masks: the button that removes the SAM 3 model shows its size, runs+//! `segment.model.remove` and goes away with the files. No model is downloaded or loaded; the files are stand-ins.++use std::time::Duration;++use lightcraft_engine::segment::Segmenter;+use serde_json::json;++use crate::headless::Headless;+use crate::{LightcraftApp, Services};++const T: Duration = Duration::from_secs(20);++/// The model folder of a test, removed again when the test ends, passed or not.+struct Folder(std::path::PathBuf);++impl Drop for Folder {+ fn drop(&mut self) {+ let _ = std::fs::remove_dir_all(&self.0);+ }+}++fn has(h: &Headless, id: &str) -> bool {+ h.app.widgets.iter().any(|(w, _)| w == id)+}++#[test]+fn settings_removes_the_mask_model_and_the_button_shows_its_size() {+ let dir = Folder(std::env::temp_dir().join(format!("lc-ui-sam-remove-{}", std::process::id())));+ let _ = std::fs::remove_dir_all(&dir.0);+ std::fs::create_dir_all(&dir.0).unwrap();+ for name in ["model.safetensors", "vocab.json", "merges.txt"] {+ std::fs::write(dir.0.join(name), vec![0u8; 2_000_000]).unwrap();+ }+ std::fs::write(dir.0.join("notes.txt"), b"mine").unwrap();+ let mut session = lightcraft_engine::Session::with_demo();+ session.segmenter.dir = Some(dir.0.clone());+ let mut h = Headless::new(LightcraftApp::new(session, Services::default()), [1200.0, 800.0], 1.0);+ h.settle(T);+ let open = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "denoise"}}), T);+ assert_eq!(open["ok"], true, "{open}");+ h.settle(T);+ for _ in 0..4 {+ h.step();+ }+ if !Segmenter::AVAILABLE {+ // a build without AI masks offers nothing to remove+ assert!(!has(&h, "settings:sam3Remove"));+ return;+ }+ // Given the model's files, the button names their size+ assert!(has(&h, "settings:sam3Remove"), "{:?}", h.app.widgets);+ assert!(h.painted_text().iter().any(|t| t.contains("Remove SAM 3 (6.0 MB)")), "{:?}", h.painted_text());+ // When it is pressed+ let r = h.request("ui.clickWidget", json!({"id": "settings:sam3Remove"}), T);+ assert_eq!(r["ok"], true, "{r}");+ h.settle(T);+ for _ in 0..4 {+ h.step();+ }+ // Then the model's files are gone, the others stay, and the button with them+ assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 1);+ assert!(dir.0.join("notes.txt").is_file());+ assert!(!has(&h, "settings:sam3Remove"));+}@@ -104,7 +104,9 @@ dialog says why and LightCraft carries on without the model. Agents and scripts use the same commands (control channel, `lightcraft-cli mcp --connect`): `segment.model.status`, `segment.model.download {"acknowledged": true}` (refused without the explicit `acknowledged`: pass it only after the user agreed to the download and the licence),-`segment.model.cancel`.+`segment.model.cancel`, `segment.model.remove {"id": "sam3"}` (deletes the model's files and partial downloads+from the model folder, nothing else, to get the room back; Settings ▸ AI Denoise ▸ AI masks has it as a button that+names the size). ### Download locations @@ -190,9 +192,10 @@ Therefore: | `mask.objectPoint` | `{x, y, exclude?: bool, id?}` | one click on the selected mask's Object selection (≤ 64 clicks) | | `mask.refineDetail` | `{id?, component?}` | the zoomed-in detail pass → `{started}` | | `segment.prepare` | `{}` | load the model and analyze the active photo → `{busy}` |-| `segment.model.status` | `{}` | `{available, installed, remote, dir, loaded, busy, analyzing, sizeBytes, license, licenseUrl, mirrors, download}` |+| `segment.model.status` | `{}` | `{available, installed, remote, dir, loaded, busy, analyzing, sizeBytes, diskBytes, license, licenseUrl, mirrors, download}` | | `segment.model.download` | `{acknowledged: true}` | start the download in the background → `{started}` | | `segment.model.cancel` | `{}` | stop it (it resumes next time) → `{cancelled}` |+| `segment.model.remove` | `{id: "sam3"}` | delete the model's files and partial downloads from the model folder, nothing else; refused while it downloads or computes a mask → `{removed, bytes}` | `remote` reports the configured endpoint, not its health. `installed` describes local weights only. `download` is `{running, done, total, file, error, cancelled, finished}`: `cancelled` is true when the last@@ -293,7 +293,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) |-| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |+| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:segment.model.remove`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License; Settings ▸ AI Denoise ▸ AI masks removes them again, with their size on the button): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt |@@ -1,5 +1,10 @@ # What's new in LightCraft +## 2026-10-26++### Remove the AI mask model from Settings+- The SAM 3 model behind Object and Describe masks takes up to 3.4 GB. Settings ▸ AI Denoise now has an AI masks section with a Remove SAM 3 button that shows the size it frees; `segment.model.remove` does the same for scripts. Only the model's files and its partial downloads go; masks already made stay, and the next new selection offers the download again.+ ## 2026-10-24 ### Relative-date filters and smart albums follow the clock