From 51d98f9256616b1d989412a9370712c782c1f01d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:36 +0200 Subject: [PATCH 1/2] Headless sessions honour LIGHTCRAFT_SAM3_DIR for the SAM 3 model folder (#619) --- crates/mcp/src/headless.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index acbecb60b..22902b849 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -30,9 +30,18 @@ impl Drop for Headless { } } +/// Point the session's SAM 3 model folder at `dir` (the `LIGHTCRAFT_SAM3_DIR` override, as the desktop app does); unset or empty keeps the default. +fn sam_dir_from(mut session: Session, dir: Option) -> Session { + if let Some(dir) = dir.filter(|d| !d.is_empty()) { + session.segmenter.dir = Some(std::path::PathBuf::from(dir)); + } + session +} + impl Default for Headless { fn default() -> Self { - Self::new(Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models()) + let session = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); + Self::new(sam_dir_from(session, std::env::var_os("LIGHTCRAFT_SAM3_DIR"))) } } @@ -43,7 +52,8 @@ impl Headless { /// A headless session with the procedurally generated demo library. pub fn demo() -> Self { - Self::new(Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models()) + let session = Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); + Self::new(sam_dir_from(session, std::env::var_os("LIGHTCRAFT_SAM3_DIR"))) } fn photo_or_active(&self, p: &Value) -> Result { @@ -201,6 +211,19 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> mod tests { use super::*; + /// `LIGHTCRAFT_SAM3_DIR` sets the model folder that `segment.model.status` reports (issue #619). + #[test] + fn sam_dir_override_reaches_the_session() { + let dir = std::env::temp_dir().join("lc-mcp-sam3-dir"); + let session = sam_dir_from(Session::new(), Some(dir.clone().into_os_string())); + assert_eq!(session.segmenter.dir.as_deref(), Some(dir.as_path())); + assert_eq!(sam_dir_from(Session::new(), None).segmenter.dir, None); + assert_eq!(sam_dir_from(Session::new(), Some(std::ffi::OsString::new())).segmenter.dir, None); + let mut h = Headless::new(session); + let r = h.call("engine.execute", json!({"command": "segment.model.status", "params": {}})).unwrap(); + assert_eq!(r["dir"], json!(dir.display().to_string()), "{r}"); + } + /// A headless session stamps imports and edits with the system clock, not the engine's fixed test clock. #[test] fn headless_uses_the_system_clock() { From ea224d899efe5c48144288374ab0a1989c306967 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:27:50 +0200 Subject: [PATCH 2/2] Share the SAM 3 model folder default between desktop and headless sessions (#619) --- apps/lightcraft/src/main.rs | 6 ++---- crates/engine/src/config.rs | 38 +++++++++++++++++++++++++++++++++++-- crates/mcp/src/headless.rs | 25 +++++++++--------------- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 00665e87e..75f3e5c61 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -761,10 +761,8 @@ fn main() -> eframe::Result { let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) - session.segmenter.dir = - std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3"))); - // the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) - session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt")); + // and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) + session = session.with_default_sam3_model(); let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref())); if let Some(ui) = prefs { app.ui = ui; diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index 7ab9b6531..5931e01c7 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -3,8 +3,8 @@ //! //! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model //! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no -//! face- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`], -//! [`Session::with_default_denoise_models`]), so tests stay hermetic. +//! face-, denoise- or SAM 3 model folder until a host asks for one ([`Session::with_default_face_models`], +//! [`Session::with_default_denoise_models`], [`Session::with_default_sam3_model`]), so tests stay hermetic. use std::path::PathBuf; @@ -37,7 +37,25 @@ pub fn default_denoise_models_dir() -> Option { .or_else(|| config_dir().map(|d| d.join("denoise-models"))) } +/// Where the SAM 3 model is kept: `$LIGHTCRAFT_SAM3_DIR` if set, else `/models/sam3`. +pub fn default_sam3_dir() -> Option { + sam3_dir(std::env::var_os("LIGHTCRAFT_SAM3_DIR"), config_dir()) +} + +/// [`default_sam3_dir`] for a given override and config folder: a non-empty override wins. +fn sam3_dir(var: Option, config: Option) -> Option { + var.filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config.map(|d| d.join("models").join("sam3"))) +} + impl Session { + /// Look for the SAM 3 model in the shared default folder ([`default_sam3_dir`]), and for the user's + /// download mirrors in `/models/sam3-mirrors.txt`. + pub fn with_default_sam3_model(mut self) -> Self { + self.segmenter.dir = default_sam3_dir(); + self.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt")); + self + } + /// Keep face models in the shared default folder ([`default_face_models_dir`]). pub fn with_default_face_models(mut self) -> Self { self.face_models_dir = default_face_models_dir(); @@ -56,3 +74,19 @@ impl Session { self.denoise.touch(); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A non-empty `LIGHTCRAFT_SAM3_DIR` wins; unset or empty falls back to `/models/sam3` (issue #619). + #[test] + fn sam3_dir_prefers_the_override() { + let config = PathBuf::from("cfg"); + let fallback = Some(config.join("models").join("sam3")); + assert_eq!(sam3_dir(Some("elsewhere".into()), Some(config.clone())), Some(PathBuf::from("elsewhere"))); + assert_eq!(sam3_dir(None, Some(config.clone())), fallback); + assert_eq!(sam3_dir(Some(std::ffi::OsString::new()), Some(config)), fallback); + assert_eq!(sam3_dir(None, None), None); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 22902b849..7814c8d84 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -30,18 +30,10 @@ impl Drop for Headless { } } -/// Point the session's SAM 3 model folder at `dir` (the `LIGHTCRAFT_SAM3_DIR` override, as the desktop app does); unset or empty keeps the default. -fn sam_dir_from(mut session: Session, dir: Option) -> Session { - if let Some(dir) = dir.filter(|d| !d.is_empty()) { - session.segmenter.dir = Some(std::path::PathBuf::from(dir)); - } - session -} - impl Default for Headless { fn default() -> Self { let session = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); - Self::new(sam_dir_from(session, std::env::var_os("LIGHTCRAFT_SAM3_DIR"))) + Self::new(session.with_default_sam3_model()) } } @@ -53,7 +45,7 @@ impl Headless { /// A headless session with the procedurally generated demo library. pub fn demo() -> Self { let session = Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); - Self::new(sam_dir_from(session, std::env::var_os("LIGHTCRAFT_SAM3_DIR"))) + Self::new(session.with_default_sam3_model()) } fn photo_or_active(&self, p: &Value) -> Result { @@ -211,14 +203,15 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> mod tests { use super::*; - /// `LIGHTCRAFT_SAM3_DIR` sets the model folder that `segment.model.status` reports (issue #619). + /// Headless sessions look for the SAM 3 model where the desktop app does, and `segment.model.status` reports it (issue #619). #[test] - fn sam_dir_override_reaches_the_session() { + fn headless_sessions_get_the_sam3_model_folder() { + let dir = lightcraft_engine::config::default_sam3_dir(); + assert_eq!(Headless::default().session.segmenter.dir, dir); + assert_eq!(Headless::demo().session.segmenter.dir, dir); let dir = std::env::temp_dir().join("lc-mcp-sam3-dir"); - let session = sam_dir_from(Session::new(), Some(dir.clone().into_os_string())); - assert_eq!(session.segmenter.dir.as_deref(), Some(dir.as_path())); - assert_eq!(sam_dir_from(Session::new(), None).segmenter.dir, None); - assert_eq!(sam_dir_from(Session::new(), Some(std::ffi::OsString::new())).segmenter.dir, None); + let mut session = Session::new(); + session.segmenter.dir = Some(dir.clone()); let mut h = Headless::new(session); let r = h.call("engine.execute", json!({"command": "segment.model.status", "params": {}})).unwrap(); assert_eq!(r["dir"], json!(dir.display().to_string()), "{r}");