mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user