diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 51fffa840..288cb095f 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -827,11 +827,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, 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 2a4ccb6b4..091bf5d00 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::ffi::OsString; use std::path::PathBuf; @@ -58,7 +58,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(); @@ -130,3 +148,19 @@ mod config_tests { assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value))); } } + +#[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 b10d2e998..c7a2e9b66 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -32,7 +32,8 @@ impl Drop for Headless { 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(session.with_default_sam3_model()) } } @@ -43,7 +44,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(session.with_default_sam3_model()) } fn photo_or_active(&self, p: &Value) -> Result { @@ -201,6 +203,20 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> mod tests { use super::*; + /// Headless sessions look for the SAM 3 model where the desktop app does, and `segment.model.status` reports it (issue #619). + #[test] + 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 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}"); + } + /// 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() {