diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 171435e22b..06168989a6 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -98,6 +98,10 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result match shape { MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {} MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => { + // refused here, not when the worker later fails at the model's limit after "pending" + if hint.len() + exclude.len() > crate::segment::MAX_CLICKS { + return Err(ai_err(format!("an Object selection takes at most {} clicks", crate::segment::MAX_CLICKS))); + } s.segmenter.require_model()?; return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude))); } diff --git a/crates/engine/src/segment.rs b/crates/engine/src/segment.rs index 3f95c310bd..0f99636122 100644 --- a/crates/engine/src/segment.rs +++ b/crates/engine/src/segment.rs @@ -498,6 +498,9 @@ impl Session { if !clicks.iter().any(|c| c.include) { return Err("click the object to include it first".into()); } + if clicks.len() > MAX_CLICKS { + return Err(format!("an Object selection takes at most {MAX_CLICKS} clicks")); + } #[cfg(feature = "sam")] { self.segment_wait(id, worker::Kind::Clicks(Self::model_clicks(clicks)), None)?.ok_or_else(|| "nothing was selected".to_string()) diff --git a/crates/engine/src/tests/segment.rs b/crates/engine/src/tests/segment.rs index b649dbdd7e..51d6e26e8f 100644 --- a/crates/engine/src/tests/segment.rs +++ b/crates/engine/src/tests/segment.rs @@ -240,6 +240,25 @@ fn object_clicks_are_capped() { let _ = std::fs::remove_dir_all(&dir); } +/// In background mode a new Object mask with too many clicks is refused up front, not queued. +#[test] +fn background_object_clicks_over_the_cap_are_refused_before_queueing() { + if !Segmenter::AVAILABLE { + return; + } + let mut s = Session::with_demo(); + s.segmenter.background = true; + let dir = damaged_model("later-clicks"); + s.segmenter.dir = Some(dir.clone()); + let id = s.active().unwrap(); + let points: Vec<_> = (0..=crate::segment::MAX_CLICKS).map(|_| json!([0.5, 0.5])).collect(); + let e = s.execute("mask.add", &json!({"kind": "object", "points": points})).unwrap_err().to_string(); + assert!(e.contains("at most"), "{e}"); + assert!(s.develop_of(id).unwrap().masks.is_empty()); + assert!(!s.segmenter.busy()); + let _ = std::fs::remove_dir_all(&dir); +} + #[cfg(feature = "sam")] #[test] fn a_running_model_download_shows_in_activity_and_cancels() { diff --git a/crates/segment/src/lib.rs b/crates/segment/src/lib.rs index 6fd077ff96..fba28b00e9 100644 --- a/crates/segment/src/lib.rs +++ b/crates/segment/src/lib.rs @@ -76,6 +76,8 @@ pub type Result = std::result::Result; pub const INPUT: usize = 1008; /// Side of the low-resolution masks the model predicts. pub const MASK_SIDE: usize = 288; +/// Most clicks one Object selection may carry (the engine's `MAX_CLICKS`). +pub const MAX_CLICKS: usize = 64; #[cfg(not(target_arch = "wasm32"))] const _: () = assert!(MASK_SIDE == tracker::LOW_RES && INPUT == vit::IMAGE); @@ -203,6 +205,10 @@ impl Sam3 { if clicks.is_empty() { return Err(Error::Model("no clicks".into())); } + // the prompt grows with every click: the editor takes at most this many (the engine's `MAX_CLICKS`) + if clicks.len() > MAX_CLICKS { + return Err(Error::Model(format!("at most {MAX_CLICKS} clicks, not {}", clicks.len()))); + } let (neck, tracker) = self.tracker_parts()?; if enc.tracker.is_none() { let levels = neck.forward(&enc.backbone, 3)?;