M3.2: reject stale selections and disclose approximate masks

Tie background results to their rendered source, borrow cached mattes for the
approximation notice, and cover provenance and matte access with regressions.

Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit 9922a180f5)
This commit is contained in:
grlessa
2026-10-13 12:41:24 +07:00
committed by bflatastic
parent 1d02ae775a
commit aedd3cbbb8
9 changed files with 202 additions and 13 deletions
+31 -4
View File
@@ -553,9 +553,8 @@ impl MediaCache {
}
}
/// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header`
/// (with a smart preview's stored camera tone curve).
fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo {
/// Prefer a cached source with decoder facts, preserving thumbnail/preview/full priority.
fn cached_source(&self, id: PhotoId) -> Option<&DecodedSource> {
let cached = || {
self.thumbs
.peek(&id)
@@ -563,7 +562,12 @@ impl MediaCache {
.chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1))
.chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1))
};
match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) {
cached().find(|s| s.info.is_some()).or_else(|| cached().next())
}
/// The decoder's cached facts, else `header` (with a smart preview's stored camera tone curve).
fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo {
match self.cached_source(id) {
Some(s) => s.info_or(header),
None => header,
}
@@ -1545,6 +1549,11 @@ impl crate::Session {
self.media.source_facts(id, header)
}
/// Borrow source mattes for selection notices without constructing render facts each frame.
pub fn source_mattes(&self, id: PhotoId) -> Option<&lightcraft_pipeline::masks::Mattes> {
self.media.cached_source(id).and_then(|s| s.info.as_ref()).and_then(|info| info.mattes.as_deref())
}
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
@@ -1709,6 +1718,24 @@ mod thumbnail_hash_tests {
#[cfg(test)]
mod tests {
#[test]
fn selection_notices_borrow_the_same_mattes_as_render_facts() {
let mut session = crate::Session::with_demo();
let id = session.active().unwrap();
assert!(session.source_mattes(id).is_none());
let mattes = std::sync::Arc::new(lightcraft_pipeline::masks::Mattes::default());
session.media.insert_source(
id,
super::SourceLevel::Thumb,
super::DecodedSource::new(
std::sync::Arc::new(lightcraft_raster::Rgb32f::new(8, 8)),
Some(lightcraft_pipeline::SourceInfo { mattes: Some(mattes.clone()), ..Default::default() }),
),
);
assert!(std::ptr::eq(session.source_mattes(id).unwrap(), mattes.as_ref()));
assert!(std::sync::Arc::ptr_eq(session.source_info(id).mattes.as_ref().unwrap(), &mattes));
}
use super::*;
#[test]
+90 -2
View File
@@ -363,8 +363,14 @@ impl Session {
let mut d = (*p.develop).clone();
d.masks.clear();
let content = crate::media::content_key(p);
// Reloaded lens data and camera-profile changes also change the model's rendered input.
let lens_key = serde_json::to_string(&p.embedded_lens).unwrap_or_default();
let mut h = mix(0xcbf2_9ce4_8422_2325, d.hash64());
h = mix(h, id.0);
h = mix(h, crate::camera_profiles::cache_key());
for b in lens_key.bytes() {
h = mix(h, u64::from(b));
}
for b in content.bytes() {
h = mix(h, u64::from(b));
}
@@ -411,7 +417,7 @@ impl Session {
job
}
};
Ok(worker::Job { dir, key: if region.is_some() { 0 } else { key }, render: Some(render), missing, kind, tag, reply })
Ok(worker::Job { dir, key: if region.is_some() { 0 } else { key }, source_key: key, render: Some(render), missing, kind, tag, reply })
}
/// Send a request and wait for its result (CLI, MCP, tests; or when not in background mode).
@@ -560,7 +566,8 @@ impl Session {
self.segment_queue(id, kind, tag, Some(region))?;
} else {
let patch = self.segment_wait(id, kind, Some(region))?;
let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false };
let source_key = self.segment_key(id).ok_or("no such photo")?.0;
let outcome = worker::Outcome { tag, source_key, result: Ok(patch), superseded: false };
let mut polled = Polled::default();
self.apply_outcome(outcome, &mut polled);
}
@@ -599,6 +606,22 @@ impl Session {
if o.superseded {
return;
}
let photo = match &o.tag {
Tag::Clicks { photo, .. } | Tag::NewPrompt { photo, .. } | Tag::Detail { photo, .. } => Some(*photo),
_ => None,
};
if let Some(photo) = photo
&& self.segment_key(photo).map(|(key, _)| key) != Some(o.source_key)
{
if let Tag::Clicks { photo, mask, comp, hint, exclude } = &o.tag {
let pending = PendingClicks { photo: *photo, mask: *mask, comp: *comp, hint: hint.clone(), exclude: exclude.clone() };
if self.segmenter.pending.as_ref() == Some(&pending) {
self.segmenter.pending = None;
}
}
polled.messages.push("The photo changed while the selection was being computed; try again.".into());
return;
}
match o.tag {
Tag::Wait => {}
Tag::Prepare => {
@@ -698,6 +721,71 @@ mod tests {
SegMask::from_logits(side, &l)
}
#[cfg(feature = "sam")]
#[test]
fn stale_prompt_result_does_not_add_a_mask_after_geometry_changes() {
let mut s = Session::with_demo();
let photo = s.active().unwrap();
let source_key = s.segment_key(photo).unwrap().0;
let outcome = worker::Outcome {
source_key,
tag: Tag::NewPrompt { photo, mask: None, op: "add".into(), name: None, text: "sky".into() },
result: Ok(Some(seg_box(16, 0, 0, 16, 8))),
superseded: false,
};
let mut d = (*s.develop_of(photo).unwrap()).clone();
d.geometry.horizontal = 30.0;
s.set_develop(photo, d, "Perspective").unwrap();
let mut polled = Polled::default();
s.apply_outcome(outcome, &mut polled);
assert!(s.develop_of(photo).unwrap().masks.is_empty(), "a mask computed in the old photo frame must not be applied");
assert!(!polled.changed);
assert!(polled.messages.iter().any(|m| m.contains("changed")));
}
#[cfg(feature = "sam")]
#[test]
fn current_prompt_result_is_applied_without_inference_or_model_weights() {
let mut s = Session::with_demo();
let photo = s.active().unwrap();
let source_key = s.segment_key(photo).unwrap().0;
let outcome = worker::Outcome {
source_key,
tag: Tag::NewPrompt { photo, mask: None, op: "add".into(), name: None, text: "sky".into() },
result: Ok(Some(seg_box(16, 0, 0, 16, 8))),
superseded: false,
};
let mut polled = Polled::default();
s.apply_outcome(outcome, &mut polled);
assert!(polled.changed && polled.messages.is_empty());
assert!(matches!(s.develop_of(photo).unwrap().masks[0].components[0].shape, MaskShape::Prompt { seg: Some(_), .. }));
}
#[cfg(feature = "sam")]
#[test]
fn stale_detail_result_does_not_overwrite_an_existing_mask() {
let mut s = Session::with_demo();
let photo = s.active().unwrap();
let seg = seg_box(16, 4, 4, 6, 6);
let shape = MaskShape::Prompt { text: "flower".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 };
s.execute("mask.add", &serde_json::json!({"kind":"prompt","text":"flower","seg":seg})).unwrap();
let source_key = s.segment_key(photo).unwrap().0;
let mask = s.active_mask.unwrap();
let mut d = (*s.develop_of(photo).unwrap()).clone();
d.orientation = lightcraft_geom::Orientation::Rotate90;
s.set_develop(photo, d, "Rotate").unwrap();
let outcome = worker::Outcome {
source_key,
tag: Tag::Detail { photo, mask, comp: 0, shape },
result: Ok(Some(seg_box(16, 5, 5, 7, 7))),
superseded: false,
};
let mut polled = Polled::default();
s.apply_outcome(outcome, &mut polled);
assert!(!polled.changed);
assert!(matches!(&s.develop_of(photo).unwrap().masks[0].components[0].shape,MaskShape::Prompt { detail,.. } if detail.is_empty()));
}
#[test]
fn detail_region_frames_the_selection_with_a_margin() {
// a small object in the middle of a square photo
+6 -3
View File
@@ -50,6 +50,8 @@ pub struct Job {
pub dir: PathBuf,
/// Which photo and look the request is about (the encode cache's key).
pub key: u64,
/// Identity of the full photo input, including for a detail job whose encode-cache key is zero.
pub source_key: u64,
/// Renders the model input when the cache doesn't hold `key` (always for a detail pass).
pub render: Option<RenderJob>,
/// The photo's file, when it is missing on disk (for a clearer error if rendering fails).
@@ -62,6 +64,7 @@ pub struct Job {
/// A finished request: the segmentation (`None`: nothing selected / nothing to report).
pub struct Outcome {
pub tag: Tag,
pub source_key: u64,
pub result: Result<Option<SegMask>, String>,
/// A newer request for the same selection came in; this one was skipped.
pub superseded: bool,
@@ -219,7 +222,7 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
if queue.iter().any(|later| supersedes(later, &job)) {
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
let _ = job.reply.send(Outcome { tag: job.tag, source_key: job.source_key, result: Ok(None), superseded: true });
continue;
}
let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) {
@@ -237,7 +240,7 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
}
shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst);
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
let _ = job.reply.send(Outcome { tag: job.tag, source_key: job.source_key, result, superseded: false });
}
}
@@ -405,7 +408,7 @@ mod tests {
fn job(tag: Tag, reply: &mpsc::Sender<Outcome>) -> Job {
// an empty folder: loading fails with an error (no model files)
let dir = std::env::temp_dir().join(format!("lc-sam3-worker-none-{}", std::process::id()));
Job { dir, key: 1, render: None, missing: None, kind: Kind::Prepare, tag, reply: reply.clone() }
Job { dir, key: 1, source_key: 1, render: None, missing: None, kind: Kind::Prepare, tag, reply: reply.clone() }
}
#[test]