diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -17,6 +17,8 @@ pub mod content_fill;
 mod create;
 mod edit;
 mod effect;
+pub use effect::CryptoProbeJob;
+pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
 pub mod essential;
 pub mod expr_tools;
 mod file;
diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs
--- a/crates/engine/src/cmd/effect.rs
+++ b/crates/engine/src/cmd/effect.rs
@@ -190,6 +190,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
 /// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection.
 struct CryptoHit {
     at: (effectcraft_project::ItemId, LayerId, Tick),
+    probe: std::sync::Arc<CryptoProbe>,
     aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
     layer: String,
     hash: u32,
@@ -210,13 +211,135 @@ impl CryptoHit {
     }
 }
 
-/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
-/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
-/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
-/// (the viewer's resolution).
-fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
+/// What a pick at one frame needs besides the point: the aux channels the Cryptomatte effect
+/// reads, its layer, and the effect's input buffer grid (scale, offset). Building it can render
+/// (a precomp's ID pass, the effects before the Cryptomatte effect); a point lookup in it is a
+/// few array reads. The session keeps the last one (see [`Session::crypto_probe_job`]).
+pub struct CryptoProbe {
+    key: ProbeKey,
+    grid: std::result::Result<ProbeGrid, String>,
+}
+
+#[derive(Clone)]
+struct ProbeGrid {
+    aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
+    layer: String,
+    scale: f64,
+    offset: [f64; 2],
+}
+
+/// What a probe depends on: the project revision, the effect (and its index), the frame, the
+/// scale and the render prefs ([`Session::render_opts`]: nested switches, draft shadows).
+#[derive(Clone, Copy, PartialEq, Debug)]
+struct ProbeKey {
+    revision: u64,
+    at: (effectcraft_project::ItemId, LayerId, Uid, usize),
+    frame: ProbeFrame,
+    scale: f64,
+    prefs: (bool, bool),
+}
+
+/// The frame a probe reads. On footage whose masks and effects up to the Cryptomatte effect
+/// don't change over time, its source frame: a still has one, held or remapped frames share
+/// theirs. Otherwise the comp time.
+#[derive(Clone, Copy, PartialEq, Debug)]
+enum ProbeFrame {
+    Source(i64),
+    Comp(Tick),
+}
+
+/// The session's kept probe, and the generation of the latest request: a build stores its
+/// result only while no newer request or edit came since.
+#[derive(Default)]
+pub struct CryptoProbeSlot {
+    probe: Option<std::sync::Arc<CryptoProbe>>,
+    want: Option<ProbeKey>,
+    generation: u64,
+    /// Jobs run (each on its thread), and those that built (not cancelled before they began).
+    jobs: u64,
+    builds: u64,
+}
+
+impl CryptoProbeSlot {
+    /// Drop the kept probe and cancel builds in flight.
+    pub(crate) fn clear(&mut self) {
+        (self.probe, self.want) = (None, None);
+        self.generation += 1;
+    }
+    pub(crate) fn kept(&self) -> bool {
+        self.probe.is_some()
+    }
+    pub(crate) fn builds(&self) -> (u64, u64) {
+        (self.jobs, self.builds)
+    }
+}
+
+/// A probe built off the UI thread into the session's slot.
+pub type CryptoProbeJob = Box<dyn FnOnce() + Send>;
+
+const NO_CRYPTO: &str = "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)";
+
+impl CryptoProbe {
+    /// `None` when `live` turns false between its stages (a newer request or an edit since):
+    /// a cancelled build stops early and lets go of what it read.
+    fn build(r: &effectcraft_render::Renderer, key: ProbeKey, time: Tick, live: &dyn Fn() -> bool) -> Option<CryptoProbe> {
+        let (cid, lid, _, pos) = key.at;
+        let build = || -> std::result::Result<Option<ProbeGrid>, String> {
+            let comp = r.project.comp(cid).ok_or("no composition")?;
+            let layer = comp.layer(lid).ok_or("no such layer")?;
+            let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
+            let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
+            let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
+            let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
+            let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
+            if !live() {
+                return Ok(None);
+            }
+            let layer_name = aux.resolve_crypto_layer(want).ok_or(NO_CRYPTO)?;
+            // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale
+            // up, proxies have their own size, earlier effects can pad it), rendered and cached.
+            if !live() {
+                return Ok(None);
+            }
+            let b = r.layer_input(&ctx, layer, pos).ok_or("this layer has no pixels")?;
+            Ok(Some(ProbeGrid { aux, layer: layer_name, scale: b.scale, offset: b.offset }))
+        };
+        // On another thread no command guard runs: a panic becomes the probe's error.
+        let grid = crate::guard::guarded("Reading the Cryptomatte IDs", || Ok(build())).unwrap_or_else(|e| Err(e.to_string()));
+        Some(CryptoProbe { key, grid: grid.transpose()? })
+    }
+}
+
+/// Whether a property in `g` other than `skip` changes over time (keyframes or an expression).
+fn moves(g: &PropGroup, skip: Option<Uid>) -> bool {
+    g.children.iter().any(|c| match c {
+        effectcraft_project::Node::Prop(p) => Some(p.uid) != skip && (p.is_animated() || p.has_expression()),
+        effectcraft_project::Node::Group(g) => moves(g, skip),
+    })
+}
+
+/// [`ProbeFrame`] of the effect at index `pos` of `layer`.
+fn probe_frame(ctx: &effectcraft_render::EvalCtx, layer: &Layer, pos: usize) -> ProbeFrame {
+    let comp = ProbeFrame::Comp(ctx.time);
+    let effectcraft_project::LayerSource::Footage { item } = layer.source else { return comp };
+    let Some(effectcraft_project::ItemKind::Footage(f)) = ctx.project.item(item).map(|i| &i.kind) else { return comp };
+    let fx = layer.effects().into_iter().flat_map(|fx| fx.groups().take(pos + 1));
+    let time_fx = |g: &PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effect.starts_with("ec.time."));
+    let still = f.kind == effectcraft_project::FootageKind::Still;
+    let blend = !still && ctx.comp.enable_frame_blending && layer.switches.frame_blend != effectcraft_project::FrameBlend::Off;
+    // The Cryptomatte effect's own Selection isn't read by the probe: animated, it keeps it.
+    let own = |i: usize, g: &PropGroup| if i == pos { g.prop("selection").map(|p| p.uid) } else { None };
+    if blend || layer.masks().is_some_and(|m| moves(m, None)) || fx.enumerate().any(|(i, g)| moves(g, own(i, g)) || time_fx(g)) {
+        return comp;
+    }
+    ProbeFrame::Source(if still { 0 } else { effectcraft_render::frame_position(ctx.source_time(layer), f.frame_rate).0 })
+}
+
+/// The Cryptomatte effect `{layer?, effect?, scale?, time?}` names: its probe key, the comp
+/// time, its layer, group and Selection property.
+#[allow(clippy::type_complexity)]
+fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick, &'a Layer, &'a PropGroup, &'a effectcraft_project::Property)> {
     let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
-    let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
     let scale = f_p(p, "scale").unwrap_or(1.0);
     if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) {
         return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1"));
@@ -227,30 +350,175 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
     let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
     let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
     let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
-    let t = super::time_p(s, p, Some(comp));
-    let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
-    let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
-    let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() });
-    let aux = r.layer_aux(&ctx, layer);
-    let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
-    let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else {
-        return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"));
+    let time = super::time_p(s, p, Some(comp));
+    let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
+    let frame = probe_frame(&ctx, layer, pos);
+    let o = s.render_opts(Default::default());
+    let prefs = (o.nested_switches, o.draft_shadows);
+    Ok((ProbeKey { revision: s.revision, at: (cid, lid, fx_uid, pos), frame, scale, prefs }, time, layer, g, prop))
+}
+
+/// The kept probe if it answers for the current revision, for [`keep_crypto_probe`] (taken
+/// before an undo, redo or history jump).
+pub(crate) fn kept_crypto_probe(s: &Session) -> Option<std::sync::Arc<CryptoProbe>> {
+    s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key.revision == s.revision)
+}
+
+/// After an undo, redo or history jump from `before` (which dropped the probe): `kept`, the
+/// probe from before, still answers when only Cryptomatte Selections changed, which the probe
+/// doesn't read (undoing a pick, as the pick itself keeps it).
+pub(crate) fn keep_crypto_probe(s: &Session, kept: Option<std::sync::Arc<CryptoProbe>>, before: &effectcraft_project::Project) {
+    let Some(p) = kept else { return };
+    if !only_crypto_selections_differ(before, &s.project) {
+        return;
+    }
+    if let Ok(mut slot) = s.crypto_probe.lock()
+        && slot.probe.is_none()
+    {
+        slot.probe = Some(std::sync::Arc::new(CryptoProbe { key: ProbeKey { revision: s.revision, ..p.key }, grid: p.grid.clone() }));
+    }
+}
+
+/// Whether projects `a` and `b` differ at most in the Selections of Cryptomatte effects.
+fn only_crypto_selections_differ(a: &effectcraft_project::Project, b: &effectcraft_project::Project) -> bool {
+    let mut x = a.clone();
+    for (cid, it) in x.items.iter_mut() {
+        let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };
+        let sels: Vec<(LayerId, Uid)> = c
+            .layers
+            .iter()
+            .flat_map(|l| {
+                l.effects()
+                    .into_iter()
+                    .flat_map(|fx| fx.groups())
+                    .filter(|g| g.match_id == "ec.3d.cryptomatte")
+                    .filter_map(move |g| g.get("selection").map(|p| (l.id, p.uid)))
+            })
+            .collect();
+        if sels.is_empty() {
+            continue;
+        }
+        let Some(bc) = b.comp(*cid) else { return false };
+        let c = std::sync::Arc::make_mut(c);
+        for (lid, uid) in sels {
+            let Some(src) = bc.layer(lid).and_then(|l| l.props.find(uid)) else { return false };
+            if let Some(dst) = c.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) {
+                *dst = src.clone();
+            }
+        }
+    }
+    x.same_content(b)
+}
+
+/// The session's probe when it has `key`'s.
+fn cached_probe(s: &Session, key: ProbeKey) -> Option<std::sync::Arc<CryptoProbe>> {
+    s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key == key)
+}
+
+/// [`Session::crypto_probe_ready`].
+pub(crate) fn crypto_probe_ready(s: &Session, p: &Value) -> Result<bool> {
+    let (key, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
+    Ok(cached_probe(s, key).is_some())
+}
+
+/// [`Session::crypto_probe_job`].
+pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoProbeJob>> {
+    let (key, time, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
+    let generation = {
+        let mut slot = s.crypto_probe.lock().map_err(|_| EngineError::Other("the Cryptomatte probe is unavailable".into()))?;
+        if slot.probe.as_ref().is_some_and(|p| p.key == key) {
+            return Ok(None);
+        }
+        // A request for another probe cancels the one in flight.
+        if slot.want != Some(key) {
+            (slot.want, slot.generation) = (Some(key), slot.generation + 1);
+        }
+        slot.generation
     };
-    // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up,
-    // proxies have their own size, earlier effects can pad it), rendered and cached.
-    let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
+    let (project, footage, expr, cache, accel, slot) =
+        (s.project.clone(), s.footage.clone(), s.expr.clone(), s.layer_cache.clone(), s.accel.clone(), s.crypto_probe.clone());
+    let opts = s.render_opts(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
+    // The slot while this is still the newest request.
+    fn current(slot: &std::sync::Mutex<CryptoProbeSlot>, generation: u64) -> Option<std::sync::MutexGuard<'_, CryptoProbeSlot>> {
+        slot.lock().ok().filter(|s| s.generation == generation)
+    }
+    Ok(Some(Box::new(move || {
+        match slot.lock() {
+            Ok(mut s) if s.generation == generation => (s.jobs, s.builds) = (s.jobs + 1, s.builds + 1),
+            Ok(mut s) => return s.jobs += 1,
+            Err(_) => return,
+        }
+        let mut r = effectcraft_render::Renderer::new(&project, footage.as_ref(), opts);
+        r.expr = expr.as_deref();
+        r.cache = Some(&cache);
+        r.accel = accel.as_deref();
+        // Checked between its stages too: a superseded build stops at the next one.
+        let Some(probe) = CryptoProbe::build(&r, key, time, &|| current(&slot, generation).is_some()) else { return };
+        let probe = std::sync::Arc::new(probe);
+        // Only the newest request's result, and never over a newer probe.
+        if let Some(mut s) = current(&slot, generation)
+            && s.probe.as_ref().is_none_or(|p| p.key.revision <= key.revision)
+        {
+            s.probe = Some(probe);
+        }
+    })))
+}
+
+/// What a pick finds at a point.
+enum Hit {
+    /// `probeOnly` and the probe isn't built yet.
+    Pending,
+    Nothing,
+    Id(CryptoHit),
+}
+
+/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
+/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
+/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
+/// (the viewer's resolution). Without a kept probe it builds one, unless `probeOnly`.
+fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
+    let (key, time, layer, g, prop) = probe_key(s, p, cmd)?;
+    let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
+    let probe = match cached_probe(s, key) {
+        Some(probe) => probe,
+        None if b_p(p, "probeOnly").unwrap_or(false) => return Ok(Hit::Pending),
+        None => {
+            let r = s.renderer(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
+            if let Ok(mut slot) = s.crypto_probe.lock() {
+                slot.clear();
+                slot.builds += 1;
+            }
+            // (Never cancelled here: nothing else runs on this thread.)
+            let Some(probe) = CryptoProbe::build(&r, key, time, &|| true).map(std::sync::Arc::new) else { return Ok(Hit::Pending) };
+            if let Ok(mut slot) = s.crypto_probe.lock() {
+                slot.probe = Some(probe.clone());
+            }
+            probe
+        }
+    };
+    let b = probe.grid.as_ref().map_err(|e| bad(cmd, e.clone()))?;
     let (bs, o) = (b.scale, b.offset);
     // The centre of its pixel under the point, at the aux point the effect reads there.
     let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]);
-    let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) };
-    let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled));
-    Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
+    let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
+    let (cid, lid, ..) = key.at;
+    let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
+    let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
+    let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
+    let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
+    let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
+    Ok(Hit::Id(CryptoHit { at: (cid, lid, time), probe: probe.clone(), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
 }
 
 /// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per
-/// frame renders the effect's input to learn its pixel grid (cached afterwards).
+/// frame renders the effect's input to learn its pixel grid (kept afterwards); with `probeOnly`
+/// it answers `{pending: true}` instead.
 fn crypto_at(s: &mut Session, p: &Value) -> Result<Value> {
-    Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection)))
+    Ok(match crypto_hit(s, p, "effect.cryptomatteAt")? {
+        Hit::Pending => json!({"pending": true}),
+        Hit::Nothing => Value::Null,
+        Hit::Id(h) => h.reply(&h.selection),
+    })
 }
 
 /// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it
@@ -264,7 +532,11 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
         "remove" => PickMode::Remove,
         m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))),
     };
-    let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?;
+    let h = match crypto_hit(s, p, cmd)? {
+        Hit::Id(h) => h,
+        Hit::Pending => return Ok(json!({"pending": true, "changed": false})),
+        Hit::Nothing => return Err(bad(cmd, "no Cryptomatte ID there (empty or outside the layer)")),
+    };
     if h.driven {
         return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick"));
     }
@@ -274,6 +546,19 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
     if changed {
         let (cid, lid, t) = h.at;
         s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?;
+        // The step says what made it (Edit ▸ Undo Pick Cryptomatte, the History panel), not
+        // "Change Property" like any other edit. (A changed Selection is always a new step.)
+        if let Some(step) = s.history.undo.last_mut() {
+            step.0 = "Pick Cryptomatte".into();
+        }
+        // Only the Selection changed, which the probe doesn't read: the next hover keeps it.
+        // (The edit dropped it; a probe built since is newer.)
+        if let Ok(mut slot) = s.crypto_probe.lock()
+            && slot.probe.is_none()
+        {
+            let key = ProbeKey { revision: s.revision, ..h.probe.key };
+            slot.probe = Some(std::sync::Arc::new(CryptoProbe { key, grid: h.probe.grid.clone() }));
+        }
     }
     let mut out = h.reply(&sel);
     out["selection"] = json!(sel);
@@ -732,14 +1017,14 @@ pub fn specs() -> Vec<CommandSpec> {
             "Pick Cryptomatte",
             [],
             None,
-            "{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point",
+            "{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?, probeOnly?} → {name, hash, layer, selection, changed, selected} | {pending} — edits the Cryptomatte effect's Selection with the ID under the point",
             has_layers,
             pick_crypto
         ),
         crate::query!(
             "effect.cryptomatteAt",
             "Cryptomatte At",
-            "{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point",
+            "{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?, probeOnly?: bool (don't build the probe)} → {name, hash, layer, selected} | {pending} | null — the Cryptomatte ID under the point",
             crypto_at
         ),
         cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs
--- a/crates/engine/src/history.rs
+++ b/crates/engine/src/history.rs
@@ -345,6 +345,7 @@ impl Session {
         let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
         let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
         let edited = crate::commands::text_edit::active_editor_snapshot(self);
+        let probe = crate::commands::kept_crypto_probe(self);
         let cur = std::mem::replace(&mut self.project, a.state[target].clone());
         self.keep_view_settings(&cur);
         self.history.undo = undo;
@@ -353,6 +354,7 @@ impl Session {
         self.history.merge_key = None;
         self.sanitize_after_replacement(edited.as_ref());
         self.bump();
+        crate::commands::keep_crypto_probe(self, probe, &cur);
         true
     }
 }
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -162,6 +162,10 @@ pub struct Session {
     pub journal: Vec<(String, Value)>,
     /// Processed-layer pixels reused across frames and edits (content-keyed, never stale).
     pub layer_cache: Arc<LayerCache>,
+    /// The last Cryptomatte pick probe (see [`Session::crypto_probe_job`]). Public only so that
+    /// other crates can build a `Session { .. }`; its type is crate-private.
+    #[doc(hidden)]
+    pub crypto_probe: Arc<std::sync::Mutex<commands::CryptoProbeSlot>>,
     /// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled.
     pub disk_cache: Option<Arc<effectcraft_render::disk_cache::DiskCache>>,
     /// Settings (Preferences).
@@ -310,6 +314,7 @@ impl Default for Session {
             events: vec![],
             journal: vec![],
             layer_cache: Arc::new(LayerCache::default()),
+            crypto_probe: Default::default(),
             disk_cache: None,
             prefs: prefs::Prefs::default(),
             prefs_revision: 0,
diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs
--- a/crates/engine/src/session/edit.rs
+++ b/crates/engine/src/session/edit.rs
@@ -91,6 +91,8 @@ impl Session {
 
     pub fn bump(&mut self) {
         self.revision += 1;
+        // A Cryptomatte probe is keyed by the revision: an older one never answers again.
+        self.drop_crypto_probe();
         // Back at the saved state (undo, redo, a history jump or a rolled-back batch).
         if Arc::ptr_eq(&self.project, &self.saved_project) {
             self.saved_revision = self.revision;
@@ -166,12 +168,14 @@ impl Session {
     pub fn undo(&mut self) -> bool {
         let Some((label, p)) = self.history.undo.pop() else { return false };
         let edited = commands::text_edit::active_editor_snapshot(self);
+        let probe = commands::kept_crypto_probe(self);
         let cur = std::mem::replace(&mut self.project, p);
         self.keep_view_settings(&cur);
-        self.history.redo.push((label, cur));
         self.history.merge_key = None;
         self.sanitize_after_replacement(edited.as_ref());
         self.bump();
+        commands::keep_crypto_probe(self, probe, &cur);
+        self.history.redo.push((label, cur));
         true
     }
 
@@ -182,12 +186,14 @@ impl Session {
     pub fn redo(&mut self) -> bool {
         let Some((label, p)) = self.history.redo.pop() else { return false };
         let edited = commands::text_edit::active_editor_snapshot(self);
+        let probe = commands::kept_crypto_probe(self);
         let cur = std::mem::replace(&mut self.project, p);
         self.keep_view_settings(&cur);
-        self.history.undo.push((label, cur));
         self.history.merge_key = None;
         self.sanitize_after_replacement(edited.as_ref());
         self.bump();
+        commands::keep_crypto_probe(self, probe, &cur);
+        self.history.undo.push((label, cur));
         true
     }
 
diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs
--- a/crates/engine/src/session/render.rs
+++ b/crates/engine/src/session/render.rs
@@ -9,14 +9,54 @@ impl Session {
     /// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and
     /// accelerator, and the render prefs (nested switches, draft shadows) over `opts`.
     pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> {
-        let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts };
-        let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
+        let mut r = Renderer::new(&self.project, self.footage.as_ref(), self.render_opts(opts));
         r.expr = self.expr.as_deref();
         r.cache = Some(&self.layer_cache);
         r.accel = self.accel.as_deref();
         r
     }
 
+    /// `opts` with the render prefs (nested switches, draft shadows), as [`Session::renderer`].
+    pub fn render_opts(&self, opts: RenderOpts) -> RenderOpts {
+        RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }
+    }
+
+    /// Whether `effect.cryptomatteAt` with params `p` answers from the kept probe (a few array
+    /// reads; `probeOnly` queries answer `pending` until then). Changes nothing: ask it every
+    /// frame, and start [`Session::crypto_probe_job`] only when a build will actually run.
+    pub fn crypto_probe_ready(&self, p: &Value) -> Result<bool> {
+        commands::crypto_probe_ready(self, p)
+    }
+
+    /// For the Cryptomatte eyedropper: `None` when `effect.cryptomatteAt` with params `p` answers
+    /// from the kept probe, else a job that builds that probe (it may render a precomp's ID pass)
+    /// off the UI thread, for the next query to use. Asking for a job supersedes a build in
+    /// flight for another probe (it then stores nothing): ask only to run it.
+    pub fn crypto_probe_job(&self, p: &Value) -> Result<Option<commands::CryptoProbeJob>> {
+        commands::crypto_probe_job(self, p)
+    }
+
+    /// Let go of the kept Cryptomatte probe (and its aux channels), and cancel a build in
+    /// flight: the eyedropper was disarmed. Edits, footage changes and Open / New do it too.
+    pub fn drop_crypto_probe(&self) {
+        if let Ok(mut slot) = self.crypto_probe.lock() {
+            slot.clear();
+        }
+    }
+
+    /// Whether a Cryptomatte probe is kept, and how many were built so far (for tests).
+    #[doc(hidden)]
+    pub fn crypto_probe_stats(&self) -> (bool, u64) {
+        self.crypto_probe.lock().map_or((false, 0), |s| (s.kept(), s.builds().1))
+    }
+
+    /// How many probe jobs ran so far, built or cancelled before they began (for tests: each
+    /// is a thread in the eyedropper).
+    #[doc(hidden)]
+    pub fn crypto_probe_jobs(&self) -> u64 {
+        self.crypto_probe.lock().map_or(0, |s| s.builds().0)
+    }
+
     /// Render a comp frame (comp time `t`) as opaque 8-bit RGBA over the comp background, scaled
     /// so the longest side is at most `max_side` pixels (0 = full size). Returns (width, height, rgba).
     pub fn render_rgba8(&self, comp: ItemId, t: Tick, max_side: u32) -> Result<(u32, u32, Vec<u8>)> {
@@ -95,5 +135,6 @@ impl Session {
             self.footage.forget(p);
         }
         self.layer_cache.clear();
+        self.drop_crypto_probe();
     }
 }
diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs
--- a/crates/engine/src/tests/crypto_pick.rs
+++ b/crates/engine/src/tests/crypto_pick.rs
@@ -334,3 +334,454 @@ fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() {
         assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
     }
 }
+
+/// `w` × `h` footage whose Cryptomatte has an ID per 37 × 29 px cell (`c<col>_<row>`, all in the
+/// manifest), one aux pixel per layer pixel. Built once, as the media pool keeps decoded aux.
+struct Cells(u32, u32, std::sync::OnceLock<Arc<AuxChannels>>);
+impl Cells {
+    fn name(x: usize, y: usize) -> String {
+        format!("c{}_{}", x / 37, y / 29)
+    }
+}
+impl FootageSource for Cells {
+    fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
+        Some(Arc::new(Image::new(f.width, f.height)))
+    }
+    fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
+        Some(self.2.get_or_init(|| Arc::new(self.build())).clone())
+    }
+}
+impl Cells {
+    fn build(&self) -> AuxChannels {
+        let (w, h) = (self.0 as usize, self.1 as usize);
+        let ids = (0..w * h).map(|i| crypto_float(crypto_hash(&Cells::name(i % w, i / w)))).collect();
+        let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
+        aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])];
+        let names = (0..w.div_ceil(37)).flat_map(|x| (0..h.div_ceil(29)).map(move |y| Cells::name(x * 37, y * 29)));
+        aux.manifests = vec![("CryptoObject".into(), names.map(|n| (n.clone(), crypto_hash(&n))).collect())];
+        aux
+    }
+}
+
+struct CellsProbe(u32, u32);
+impl crate::Importer for CellsProbe {
+    fn probe(&self, path: &str) -> Result<Footage, String> {
+        // `…_proxy` is a half-size proxy (rounded up); `…still…` a still image.
+        let (width, height) = if path.contains("proxy") { (self.0.div_ceil(2), self.1.div_ceil(2)) } else { (self.0, self.1) };
+        let (frame_rate, duration) = (FrameRate::from_f64(24.0), Tick::from_seconds_f64(2.0));
+        let kind = if path.contains("still") { FootageKind::Still } else { FootageKind::Video };
+        Ok(Footage { path: path.into(), kind, width, height, has_video: true, frame_rate, duration, ..Default::default() })
+    }
+}
+
+fn cells_session(w: u32, h: u32, blur: bool) -> Session {
+    cells_session_at(w, h, blur, "/renders/cells.exr")
+}
+
+fn cells_session_at(w: u32, h: u32, blur: bool, path: &str) -> Session {
+    let mut s = Session { footage: Arc::new(Cells(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() };
+    let item = s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].clone();
+    s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap();
+    s.execute("layer.addItem", json!({"item": item})).unwrap();
+    if blur {
+        s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
+        s.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": 40.0})).unwrap();
+    }
+    s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
+    s
+}
+
+/// Hover latency of `effect.cryptomatteAt` (dev profile): the first call after the viewer's frame
+/// and the next ones, on footage (1080p, 4K, 4K behind a blur) and a 1080p 3D precomp. Run with
+/// `--ignored --nocapture`; it measures, it doesn't assert.
+#[test]
+#[ignore]
+fn hover_latency() {
+    let mut cases: Vec<(&str, Session)> = vec![
+        ("footage 1920x1080", cells_session(1920, 1080, false)),
+        ("footage 3840x2160", cells_session(3840, 2160, false)),
+        ("footage 3840x2160 + blur 40", cells_session(3840, 2160, true)),
+    ];
+    let mut s = Session::default();
+    s.execute("comp.new", json!({"name": "Outer", "width": 1920, "height": 1080, "frameRate": 24, "duration": 2})).unwrap();
+    let a = s.execute("layer.newSolid", json!({"name": "A", "width": 1920, "height": 1080})).unwrap()["layer"].clone();
+    let b = s.execute("layer.newSolid", json!({"name": "B", "width": 400, "height": 1080})).unwrap()["layer"].clone();
+    s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap();
+    s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
+    s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
+    s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
+    cases.push(("3D precomp 1920x1080", s));
+    for (name, mut s) in cases {
+        let cid = s.active_comp_id().unwrap();
+        let _ = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts::default());
+        let ms: Vec<String> = (0..6)
+            .map(|i| {
+                let t0 = std::time::Instant::now();
+                let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 100.0 + 40.0 * i as f64, "y": 500.0})).unwrap();
+                assert!(!r.is_null(), "{name}");
+                format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3)
+            })
+            .collect();
+        eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / "));
+    }
+}
+
+/// The hover's probe: built off the thread that asks, kept while only the Selection changes,
+/// rebuilt when anything it reads changes, and a job finished for an older state is not used.
+#[test]
+fn hover_probe_is_built_by_a_job_kept_across_picks_and_dropped_on_other_edits() {
+    let mut s = session();
+    let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
+    let job = s.crypto_probe_job(&q).unwrap().expect("nothing built yet");
+    std::thread::spawn(job).join().unwrap();
+    assert!(s.crypto_probe_job(&q).unwrap().is_none(), "built");
+    assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
+    // Another scale or time is another probe.
+    assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "scale": 0.5})).unwrap().is_some());
+    assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.0})).unwrap().is_some());
+    // A pick edits only the Selection: the probe stays.
+    pick(&mut s, 0.5, "replace");
+    assert!(s.crypto_probe_job(&q).unwrap().is_none(), "kept across the pick");
+    assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
+    // Other edits rebuild it, and the answer follows them.
+    s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
+    s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap();
+    let stale = s.crypto_probe_job(&q).unwrap().expect("time remap changed it");
+    assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "late");
+    // A job that finishes after the next edit doesn't answer for it.
+    s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
+    stale();
+    assert!(s.crypto_probe_job(&q).unwrap().is_some(), "stale job result is not used");
+    assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
+    // Errors are kept too, and still errors.
+    s.execute("layer.newSolid", json!({"name": "S"})).unwrap();
+    s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
+    let solid = json!({"effect": 1, "x": 1, "y": 1});
+    s.crypto_probe_job(&solid).unwrap().expect("solid")();
+    assert!(s.crypto_probe_job(&solid).unwrap().is_none());
+    assert!(s.execute("effect.cryptomatteAt", solid).unwrap_err().to_string().contains("no Cryptomatte channels"));
+}
+
+/// Picks on 1080p, 4K and odd-sized footage, at the viewer's resolutions and on a half-size proxy,
+/// name the ID the rendered matte shows under the point (points next to cell edges).
+#[test]
+fn picks_match_the_render_at_1080p_4k_odd_sizes_and_on_proxies() {
+    for (w, h) in [(1920, 1080), (3840, 2160), (1919, 1079)] {
+        let mut s = cells_session(w, h, false);
+        let (wl, hl) = (f64::from(w), f64::from(h));
+        let xs = [36.6, 37.2, 0.4, wl / 2.0 + 0.3, wl - 0.6, wl - 37.4];
+        let ys = [28.7, 29.1, hl - 0.5, hl / 3.0];
+        let mut bad = mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys);
+        let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0;
+        s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/cells_proxy.exr"})).unwrap();
+        bad.extend(mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys).into_iter().map(|b| format!("proxy {b}")));
+        assert!(bad.is_empty(), "{w}x{h}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
+    }
+}
+
+/// The kept probe, and the aux channels it holds, go when the eyedropper is disarmed, when its
+/// layer or effect is deleted, on Open / New, and when its footage changes.
+#[test]
+fn hover_probe_is_released_on_disarm_delete_open_and_footage_changes() {
+    let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 100.0});
+    let ends: [(&str, fn(&mut Session)); 5] = [
+        ("disarm", |s| s.drop_crypto_probe()),
+        ("layer delete", |s| {
+            let lid = s.active_comp().unwrap().layers[0].id.0;
+            s.execute_checked("edit.clear", json!({"layers": [lid]})).unwrap();
+        }),
+        ("effect delete", |s| {
+            s.execute_checked("effect.remove", json!({"effect": "Cryptomatte"})).unwrap();
+        }),
+        ("open / new", |s| s.replace_project(effectcraft_project::Project::default(), None)),
+        ("footage change", |s| {
+            let f = s.project.items.values().find_map(|i| match &i.kind {
+                effectcraft_project::ItemKind::Footage(f) => Some(f.clone()),
+                _ => None,
+            });
+            s.forget_decoded(&f.unwrap());
+        }),
+    ];
+    for (what, end) in ends {
+        let mut s = cells_session(640, 360, false);
+        let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap();
+        let unpinned = Arc::strong_count(&aux);
+        assert!(!s.execute("effect.cryptomatteAt", q.clone()).unwrap().is_null());
+        assert!(s.crypto_probe_stats().0 && Arc::strong_count(&aux) > unpinned, "{what}: kept");
+        end(&mut s);
+        assert!(!s.crypto_probe_stats().0, "{what}: the probe is gone");
+        assert_eq!(Arc::strong_count(&aux), unpinned, "{what}: its aux channels are let go");
+    }
+}
+
+/// A probe is keyed by the frame it reads: on a still, or footage held by Time Remap, every comp
+/// time shares one; each footage frame has its own, and so does each comp time once something
+/// before the Cryptomatte effect is animated.
+#[test]
+fn hover_probe_is_keyed_by_the_source_frame() {
+    let builds = |s: &mut Session, times: &[f64]| -> Vec<u64> {
+        let b = s.crypto_probe_stats().1;
+        times
+            .iter()
+            .map(|t| {
+                let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap();
+                assert_eq!(r["name"], "c1_1");
+                s.crypto_probe_stats().1 - b
+            })
+            .collect()
+    };
+    let f = 1.0 / 24.0;
+    let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
+    assert_eq!(builds(&mut s, &[0.0, f, 0.5, 1.9]), [1, 1, 1, 1], "a still: one probe");
+    let mut s = cells_session(320, 180, false);
+    assert_eq!(builds(&mut s, &[0.0, f * 0.4, f, f * 1.5, 0.0]), [1, 1, 2, 2, 3], "footage: a probe per frame");
+    s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
+    s.execute_checked("prop.toggleAnimation", json!({"path": "timeRemap", "value": false})).unwrap();
+    s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
+    assert_eq!(builds(&mut s, &[0.0, f, 1.2]), [1, 1, 1], "held by Time Remap: one source frame");
+    let mut s = cells_session_at(320, 180, true, "/renders/still.exr");
+    assert_eq!(builds(&mut s, &[0.0, f]), [1, 1], "a still blur");
+    s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 0.0, "value": 2.0})).unwrap();
+    s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 1.0, "value": 6.0})).unwrap();
+    assert_eq!(builds(&mut s, &[0.0, f, f]), [1, 2, 2], "an animated blur before it: per comp time");
+}
+
+/// Probes of two Cryptomatte effects (on two footage layers) at the same revision and frame
+/// don't answer for each other.
+#[test]
+fn hover_probes_are_per_layer_and_effect() {
+    /// `…late…` files have the IDs [`Crypto`] has from 1 s.
+    struct ByFile;
+    impl FootageSource for ByFile {
+        fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
+            Some(Arc::new(Image::new(f.width, f.height)))
+        }
+        fn aux(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
+            Some(Arc::new(aux_at(if f.path.contains("late") { Tick::from_seconds_f64(1.0) } else { t })))
+        }
+    }
+    let mut s = Session { footage: Arc::new(ByFile), importer: Some(Arc::new(Probe)), ..Default::default() };
+    let items = s.execute("file.import", json!({"paths": ["/renders/crypto.exr", "/renders/late.exr"]})).unwrap()["items"].clone();
+    s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
+    let layers: Vec<Value> = (0..2).map(|i| s.execute("layer.addItem", json!({"item": items[i]})).unwrap()["layer"].clone()).collect();
+    for l in &layers {
+        s.execute_checked("effect.apply", json!({"layers": [l], "effect": "Cryptomatte"})).unwrap();
+    }
+    let on = |l: &Value| json!({"layer": l, "effect": 1, "x": 0.5, "y": 1.5});
+    for (l, want) in [(&layers[0], "left"), (&layers[1], "late"), (&layers[0], "left")] {
+        assert_eq!(s.execute("effect.cryptomatteAt", on(l)).unwrap()["name"], want);
+    }
+}
+
+/// [`Crypto`]'s IDs, counting the reads, and holding them while `hold` is set (a build caught
+/// in the middle).
+#[derive(Default)]
+struct Gate {
+    reads: std::sync::atomic::AtomicUsize,
+    hold: std::sync::atomic::AtomicBool,
+    entered: std::sync::atomic::AtomicBool,
+    /// Picture frames read (the effect's input render).
+    frames: std::sync::atomic::AtomicUsize,
+}
+impl FootageSource for Gate {
+    fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
+        self.frames.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+        Some(Arc::new(Image::new(f.width, f.height)))
+    }
+    fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
+        use std::sync::atomic::Ordering::SeqCst;
+        self.reads.fetch_add(1, SeqCst);
+        self.entered.store(true, SeqCst);
+        while self.hold.load(SeqCst) {
+            std::thread::sleep(std::time::Duration::from_millis(1));
+        }
+        Some(Arc::new(aux_at(t)))
+    }
+}
+
+/// Only the newest request's build is kept: a job that finishes late doesn't evict a newer probe,
+/// a job superseded before it runs doesn't build, one superseded (or outdated by an edit) while
+/// it builds stores nothing, and `probeOnly` never builds on the asking thread.
+#[test]
+fn hover_probe_builds_store_only_the_newest_and_never_build_for_probe_only() {
+    use std::sync::atomic::Ordering::SeqCst;
+    let gate = Arc::new(Gate::default());
+    let mut s = session();
+    s.footage = gate.clone();
+    let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t});
+    let reads = || gate.reads.load(SeqCst);
+    // A late job for another frame doesn't evict the probe built since.
+    let job0 = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
+    s.execute("effect.cryptomatteAt", at(1.0)).unwrap();
+    assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none());
+    job0();
+    assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none(), "the late job evicted the newer probe");
+    // A request for another probe cancels the earlier job: it doesn't even build.
+    let (old, new) = (s.crypto_probe_job(&at(0.5)).unwrap().unwrap(), s.crypto_probe_job(&at(0.25)).unwrap().unwrap());
+    let before = reads();
+    old();
+    assert_eq!(reads(), before, "a superseded job doesn't build");
+    new();
+    assert!(reads() > before);
+    assert!(s.crypto_probe_job(&at(0.25)).unwrap().is_none(), "the newest request's probe is kept");
+    // The same request again doesn't cancel the job in flight.
+    let (a, b) = (s.crypto_probe_job(&at(1.5)).unwrap().unwrap(), s.crypto_probe_job(&at(1.5)).unwrap().unwrap());
+    a();
+    drop(b);
+    assert!(s.crypto_probe_job(&at(1.5)).unwrap().is_none());
+    // Superseded, or outdated by an edit, while it builds: it stores nothing.
+    for edit in [false, true] {
+        let job = s.crypto_probe_job(&at(0.75)).unwrap().unwrap();
+        gate.hold.store(true, SeqCst);
+        gate.entered.store(false, SeqCst);
+        let run = std::thread::spawn(job);
+        while !gate.entered.load(SeqCst) {
+            std::thread::sleep(std::time::Duration::from_millis(1));
+        }
+        if edit {
+            s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
+        } else {
+            drop(s.crypto_probe_job(&at(0.8)).unwrap());
+        }
+        gate.hold.store(false, SeqCst);
+        run.join().unwrap();
+        let stored = if edit { s.crypto_probe_stats().0 } else { s.crypto_probe_job(&at(0.75)).unwrap().is_none() };
+        assert!(!stored, "edit {edit}: the outdated build was stored");
+    }
+    // `probeOnly`: pending without a probe, and nothing built.
+    let before = reads();
+    let mut q = at(0.0);
+    q["probeOnly"] = json!(true);
+    assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap(), json!({"pending": true}));
+    q["mode"] = json!("add");
+    assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["pending"], true);
+    assert_eq!(reads(), before, "nothing built for probeOnly");
+    s.crypto_probe_job(&q).unwrap().unwrap()();
+    assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["selection"], "left");
+}
+
+/// A build cancelled while it reads the IDs (a newer request, disarming, an edit, another
+/// project) stops before it renders the effect's input, so the next build isn't kept waiting.
+#[test]
+fn a_cancelled_build_stops_before_rendering_its_input() {
+    use std::sync::atomic::Ordering::SeqCst;
+    let cancels: [(&str, fn(&mut Session)); 4] = [
+        ("newer request", |s| drop(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.5})).unwrap())),
+        ("disarm", |s| s.drop_crypto_probe()),
+        ("edit", |s| drop(s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap())),
+        ("project switch", |s| s.replace_project(effectcraft_project::Project::default(), None)),
+    ];
+    for (what, cancel) in [("not cancelled", None), ("newer request", Some(0)), ("disarm", Some(1)), ("edit", Some(2)), ("project switch", Some(3))] {
+        let gate = Arc::new(Gate::default());
+        let mut s = session();
+        s.footage = gate.clone();
+        let job = s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 0.5})).unwrap().unwrap();
+        gate.hold.store(true, SeqCst);
+        let run = std::thread::spawn(job);
+        while !gate.entered.load(SeqCst) {
+            std::thread::sleep(std::time::Duration::from_millis(1));
+        }
+        if let Some(k) = cancel {
+            cancels[k].1(&mut s);
+        }
+        gate.hold.store(false, SeqCst);
+        run.join().unwrap();
+        let rendered = gate.frames.load(SeqCst) > 0;
+        assert_eq!(rendered, cancel.is_none(), "{what}: the input was rendered: {rendered}");
+    }
+}
+
+/// The probe doesn't read the Cryptomatte effect's own Selection: keyframed, a still keeps one
+/// probe for every comp time (an animated effect before it still makes one per time).
+#[test]
+fn an_animated_selection_keeps_one_probe_on_a_still() {
+    let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
+    for (t, v) in [(0.0, "c1_1"), (1.0, "c2_2")] {
+        s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": v})).unwrap();
+    }
+    let b = s.crypto_probe_stats().1;
+    for t in [0.0, 1.0 / 24.0, 0.5, 1.5] {
+        assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap()["name"], "c1_1");
+    }
+    assert_eq!(s.crypto_probe_stats().1 - b, 1, "one probe for the still");
+}
+
+/// A panic while a probe is built on another thread becomes the probe's error: the job returns,
+/// the hover reports it, and it isn't retried for that frame.
+#[test]
+fn a_panicking_probe_build_becomes_an_error() {
+    struct Broken;
+    impl FootageSource for Broken {
+        fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
+            Some(Arc::new(Image::new(f.width, f.height)))
+        }
+        fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
+            panic!("a broken EXR reader")
+        }
+    }
+    let mut s = Session { footage: Arc::new(Broken), importer: Some(Arc::new(Probe)), ..Default::default() };
+    let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
+    s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
+    s.execute("layer.addItem", json!({"item": item})).unwrap();
+    s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
+    let q = json!({"effect": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
+    let job = s.crypto_probe_job(&q).unwrap().unwrap();
+    assert!(std::thread::spawn(job).join().is_ok(), "the job caught the panic");
+    assert!(s.crypto_probe_job(&q).unwrap().is_none(), "the error is kept");
+    let e = s.execute("effect.cryptomatteAt", q).unwrap_err().to_string();
+    assert!(e.contains("Reading the Cryptomatte IDs failed") && e.contains("a broken EXR reader"), "{e}");
+}
+
+/// Asking whether the probe is built changes nothing: asked for other frames while a build runs
+/// (the eyedropper's hover while a click waits), it doesn't cancel that build.
+#[test]
+fn asking_whether_a_probe_is_ready_cancels_no_build() {
+    let s = session();
+    let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t, "probeOnly": true});
+    let job = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
+    for t in [1.0, 0.5, 1.0, 0.25] {
+        assert!(!s.crypto_probe_ready(&at(t)).unwrap());
+    }
+    job();
+    assert!(s.crypto_probe_ready(&at(0.0)).unwrap(), "the build was cancelled");
+    assert!(s.crypto_probe_job(&at(0.0)).unwrap().is_none());
+}
+
+/// The probe renders with the render prefs (nested switches, draft shadows): changing them asks
+/// for a new one.
+#[test]
+fn a_render_prefs_change_asks_for_a_new_probe() {
+    let mut s = session();
+    let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
+    s.crypto_probe_job(&q).unwrap().unwrap()();
+    assert!(s.crypto_probe_ready(&q).unwrap());
+    s.prefs.three_d.realtime_shadows = !s.prefs.three_d.realtime_shadows;
+    assert!(!s.crypto_probe_ready(&q).unwrap(), "draft shadows changed");
+    s.crypto_probe_job(&q).unwrap().unwrap()();
+    s.prefs.general.switches_affect_nested_comps = !s.prefs.general.switches_affect_nested_comps;
+    assert!(!s.crypto_probe_ready(&q).unwrap(), "nested switches changed");
+}
+
+/// A pick is an undo step named for it, and undoing, redoing or jumping over picks keeps the
+/// probe (only the Selection changed); undoing any other edit doesn't.
+#[test]
+fn undoing_picks_keeps_the_probe_and_the_step_says_pick() {
+    let mut s = session();
+    let q = json!({"effect": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
+    s.crypto_probe_job(&q).unwrap().unwrap()();
+    let before = crate::history::state_id(&s.project);
+    pick(&mut s, 0.5, "replace");
+    assert_eq!(s.history.undo.last().unwrap().0, "Pick Cryptomatte");
+    pick(&mut s, 4.5, "add");
+    for (what, cmd) in [("undo", "edit.undo"), ("undo", "edit.undo"), ("redo", "edit.redo")] {
+        s.execute_checked(cmd, json!({})).unwrap();
+        assert!(s.crypto_probe_ready(&q).unwrap(), "{what} {}: probe dropped", selection(&s));
+    }
+    assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
+    assert!(s.goto_history(&before) && selection(&s).is_empty());
+    assert!(s.crypto_probe_ready(&q).unwrap(), "history jump: probe dropped");
+    s.execute_checked("prop.set", json!({"path": "transform/opacity", "value": 50})).unwrap();
+    s.crypto_probe_job(&q).unwrap().unwrap()();
+    s.execute_checked("edit.undo", json!({})).unwrap();
+    assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe");
+}
