This commit is contained in:
Brandon Thomas committed 2026-11-03 09:08:08 -05:00
commit f5e81cd1f9
22 files changed
+4109 -22

No files matched your search

+2 -2
View File
@@ -18,8 +18,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 use effect::{CryptoCoverage, CryptoProbeJob};
pub(crate) use effect::{CryptoProbeSlot, crypto_coverage, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
pub mod essential;
pub mod expr_tools;
mod file;
+33
View File
@@ -500,6 +500,39 @@ pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoPr
})))
}
/// [`Session::crypto_coverage`].
pub(crate) fn crypto_coverage(s: &Session, p: &Value, hash: u32, max_side: usize) -> Option<CryptoCoverage> {
let (key, ..) = probe_key(s, p, "effect.cryptomatteAt").ok()?;
let probe = cached_probe(s, key)?;
let b = probe.grid.as_ref().ok()?;
let (aw, ah) = (b.aux.width as usize, b.aux.height as usize);
if aw == 0 || ah == 0 {
return None;
}
let step = aw.max(ah).div_ceil(max_side.max(1)).max(1);
let (w, h) = (aw.div_ceil(step), ah.div_ceil(step));
let planes = b.aux.crypto_rank_planes(&b.layer);
let sample = |j: usize| {
planes.iter().filter(|(id, _)| id.get(j).is_some_and(|v| v.to_bits() == hash)).map(|(_, c)| c.get(j).map_or(0.0, |c| c.max(0.0))).sum::<f32>().min(1.0)
};
// Each sample at the centre of its step × step cell; the last cells are cut by the layer.
let at = |x: usize, y: usize| sample((y * step + step / 2).min(ah - 1) * aw + (x * step + step / 2).min(aw - 1));
let coverage = (0..w * h).map(|i| at(i % w, i / w)).collect();
let (size, uv) = ([aw as f64 / b.aux.scale, ah as f64 / b.aux.scale], [aw as f64 / (w * step) as f64, ah as f64 / (h * step) as f64]);
Some(CryptoCoverage { width: w, height: h, coverage, size, uv })
}
/// An ID's coverage over a layer, for the eyedropper's hover highlight: `width` × `height`
/// samples (row-major, 0..1). The layer, `size` effect-space pixels from its origin, shows the
/// texture from (0, 0) to `uv`.
pub struct CryptoCoverage {
pub width: usize,
pub height: usize,
pub coverage: Vec<f32>,
pub size: [f64; 2],
pub uv: [f64; 2],
}
/// What a pick finds at a point.
enum Hit {
/// `probeOnly` and the probe isn't built yet.
+7
View File
@@ -36,6 +36,13 @@ impl Session {
commands::crypto_probe_job(self, p)
}
/// The coverage of Cryptomatte ID `hash` (as `effect.cryptomatteAt` reports it, parsed) over
/// the layer of params `p`, at most `max_side` samples on the long side, from the kept probe:
/// `None` until [`Session::crypto_probe_job`] has built it.
pub fn crypto_coverage(&self, p: &Value, hash: u32, max_side: usize) -> Option<commands::CryptoCoverage> {
commands::crypto_coverage(self, p, hash, max_side)
}
/// 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) {
+103 -1
View File
@@ -431,7 +431,13 @@ fn hover_latency() {
format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3)
})
.collect();
eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / "));
// The highlight of the hovered ID (built once per ID, at most 1024 samples wide).
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0});
let hash = u32::from_str_radix(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["hash"].as_str().unwrap(), 16).unwrap();
let t0 = std::time::Instant::now();
let c = s.crypto_coverage(&q, hash, 1024).unwrap();
let cov = t0.elapsed().as_secs_f64() * 1e3;
eprintln!("HOVER {name}: first {} ms, then {} ms; highlight {}x{} {cov:.2} ms", ms[0], ms[1..].join(" / "), c.width, c.height);
}
}
@@ -794,3 +800,99 @@ fn undoing_picks_keeps_the_probe_and_the_step_says_pick() {
s.execute_checked("edit.undo", json!({})).unwrap();
assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe");
}
/// The hover highlight: an ID's coverage from the kept probe, one sample at the centre of each
/// cell of aux pixels, over the layer's extent (the last cells cut by it).
#[test]
fn coverage_of_an_id_from_the_kept_probe() {
let s = session();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
let left = crypto_hash("left");
assert!(s.crypto_coverage(&q, left, 64).is_none(), "no coverage before the probe");
s.crypto_probe_job(&q).unwrap().unwrap()();
let c = s.crypto_coverage(&q, left, 64).unwrap();
assert_eq!((c.width, c.height, c.size, c.uv), (16, 8, [8.0, 4.0], [1.0, 1.0]), "the aux pixels over the layer");
assert!(c.coverage.iter().all(|v| (0.0..=1.0).contains(v)) && c.coverage.iter().any(|v| *v > 0.5) && c.coverage.contains(&0.0), "{:?}", c.coverage);
assert_eq!(c.coverage[0], 0.6, "left's coverage at the first aux pixel");
assert!(s.crypto_coverage(&q, crypto_hash("nobody"), 64).unwrap().coverage.iter().all(|v| *v == 0.0));
assert_eq!(s.crypto_coverage(&q, left, 2).map(|c| (c.width, c.height)), Some((2, 1)));
// An odd size: 2 × 2 cells, the last ones half outside the layer; samples at cell centres.
let mut s = cells_session(1919, 1079, false);
let q = json!({"effect": "Cryptomatte", "x": 40.0, "y": 10.0});
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "c1_0");
let c = s.crypto_coverage(&q, crypto_hash("c1_0"), 1024).unwrap();
assert_eq!((c.width, c.height, c.size), (960, 540, [1919.0, 1079.0]));
assert_eq!(c.uv, [1919.0 / 1920.0, 1079.0 / 1080.0]);
// Sample 18 covers pixels 36-37: its centre (37) is in c1_0, its corner (36) in c0_0.
assert_eq!((c.coverage[17], c.coverage[18], c.coverage[37]), (0.0, 1.0, 0.0));
}
struct Ranked(u32, u32, std::sync::OnceLock<Arc<AuxChannels>>);
impl FootageSource for Ranked {
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(|| {
let (w, h) = (self.0 as usize, self.1 as usize);
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
for part in 0..3 {
for (k, c) in ["R", "G", "B", "A"].iter().enumerate() {
let rank = part * 2 + k / 2;
let v: Vec<f32> = (0..w * h)
.map(|i| if k % 2 == 0 { crypto_float(crypto_hash(&format!("r{rank}_{}", Cells::name(i % w, i / w)))) } else { 1.0 / 6.0 })
.collect();
aux.channels.push((format!("CryptoObject{part:02}.{c}"), v));
}
}
Arc::new(aux)
})
.clone(),
)
}
}
fn rss_mb() -> f64 {
let s = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
s.lines().find(|l| l.starts_with("VmRSS")).and_then(|l| l.split_whitespace().nth(1)).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) / 1024.0
}
/// Highlight cost with 6 ranks (Cryptomatte's default 3 parts) at 1080p, 4K and 8K, and the memory the
/// probe pins. Run with `--ignored --nocapture`; it measures, it doesn't assert.
#[test]
#[ignore]
fn highlight_cost_with_six_ranks() {
for (w, h) in [(1920u32, 1080u32), (3840, 2160), (7680, 4320)] {
let r0 = rss_mb();
let mut s = Session { footage: Arc::new(Ranked(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() };
let item = s.execute("file.import", json!({"paths": ["/renders/ranked.exr"]})).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();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0});
let t0 = std::time::Instant::now();
let r = s.execute("effect.cryptomatteAt", q.clone()).unwrap();
let build = t0.elapsed().as_secs_f64() * 1e3;
let hash = u32::from_str_radix(r["hash"].as_str().unwrap(), 16).unwrap();
let mut ms = vec![];
for _ in 0..5 {
let t0 = std::time::Instant::now();
let c = s.crypto_coverage(&q, hash, 1024).unwrap();
ms.push(t0.elapsed().as_secs_f64() * 1e3);
assert!(c.coverage.iter().any(|v| *v > 0.0));
}
ms.sort_by(f64::total_cmp);
let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap();
let bytes: usize = aux.channels.iter().map(|(_, v)| v.len() * 4).sum();
eprintln!(
"HIGHLIGHT 6 ranks {w}x{h}: aux {:.0} MB ({} planes), probe build {build:.1} ms, highlight median {:.2} ms (min {:.2}), RSS +{:.0} MB",
bytes as f64 / 1e6,
aux.channels.len(),
ms[2],
ms[0],
rss_mb() - r0
);
}
}
+1
View File
@@ -77,6 +77,7 @@ impl EffectcraftApp {
passive_tex: std::collections::HashMap::new(),
viewer_native: None,
viewer_shown: None,
crypto_probe_busy: Default::default(),
viewer_image: None,
viewer_readback: Default::default(),
wgpu: None,
+20 -3
View File
@@ -327,7 +327,9 @@ impl EffectcraftApp {
ctx.request_repaint();
return;
}
panels::crypto_pick::pass_start(self, &ctx);
self.frame(ui);
panels::crypto_pick::upkeep(self, &ctx);
#[cfg(not(target_arch = "wasm32"))]
pointer_wrap::finish_native(&ctx, frame);
// wasm32: no frame threads; render queued frames now, between UI frames.
@@ -454,10 +456,22 @@ impl EffectcraftApp {
}
}
/// Sets the status line and restarts its 5 s, also when the same text is still showing (a
/// message set again would otherwise keep the time it was first shown).
pub(crate) fn say(&mut self, ctx: &egui::Context, msg: String) {
ctx.data_mut(|d| d.remove::<(f64, String)>(egui::Id::unique("status-since")));
self.ui.status = msg;
}
/// The status message or the latest toast, bottom left (`lift` points higher while the
/// Importing card is there).
fn draw_toast(&mut self, ui: &mut egui::Ui, full: egui::Rect, lift: f32) {
let now = self.now;
// A message cleared before its time (a click on it, Esc, another pick) forgets its time, so
// the same message shown again later gets its own 5 s.
if self.ui.status.is_empty() {
ui.data_mut(|d| d.remove::<(f64, String)>(egui::Id::unique("status-since")));
}
let msg = if !self.ui.status.is_empty() {
Some(self.ui.status.clone())
} else {
@@ -485,11 +499,14 @@ impl EffectcraftApp {
}
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
if !self.ui.status.is_empty() {
let since: f64 = ui.data(|d| d.get_temp(egui::Id::unique("status-since")).unwrap_or(now));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("status-since"), since));
// Each message gets its own 5 s (a new one doesn't inherit the old one's time).
let key = egui::Id::unique("status-since");
let shown = ui.data(|d| d.get_temp::<(f64, String)>(key)).filter(|(_, m)| *m == self.ui.status);
let since = shown.map_or(now, |(t, _)| t);
ui.data_mut(|d| d.insert_temp(key, (since, self.ui.status.clone())));
if now - since > 5.0 {
self.ui.status.clear();
ui.data_mut(|d| d.remove::<f64>(egui::Id::unique("status-since")));
ui.data_mut(|d| d.remove::<(f64, String)>(key));
}
}
}
+3 -2
View File
@@ -405,11 +405,12 @@ impl EffectcraftApp {
return;
}
let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb);
let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n);
// The viewer's exact factor (1/3, not the key's 0.333): it shows these frames.
let scale = frames::render_scale(series.scale, comp.resolution.factor());
let fit = self.frames_that_fit(comp, scale).min(n);
// One thread each (`Frames::request_background`), so every frame thread takes one.
let slots = self.frames_parallelism().max(1);
let mut queued = self.frames.inflight();
let scale = series.scale as f64 / 1000.0;
let mut pending = false;
for k in 0..fit {
let f = wa + (cur - wa + k).rem_euclid(n);
+8
View File
@@ -1580,5 +1580,13 @@ impl Queue {
}
}
/// The render scale a viewer frame's key stands for: it keeps thousandths, but the viewer
/// renders every n-th pixel, and 0.333 is not 1/3 (2 pixels off by row 1000 at Third). The
/// comp's own factor decides when two factors share the thousandths (from 1/34).
pub(crate) fn render_scale(thousandths: u32, factor: Option<u8>) -> f64 {
let n = factor.map(f64::from).filter(|n| (1000.0 / n).round() == f64::from(thousandths));
1.0 / n.unwrap_or_else(|| (1000.0 / f64::from(thousandths.max(1))).round()).max(1.0)
}
#[cfg(test)]
mod tests;
+11
View File
@@ -1,5 +1,16 @@
use super::*;
#[test]
fn render_scales_are_the_factors_the_thousandths_stand_for() {
assert_eq!(render_scale(333, Some(3)), 1.0 / 3.0);
assert_eq!(render_scale(333, None), 1.0 / 3.0);
assert_eq!(render_scale(167, Some(6)), 1.0 / 6.0);
assert_eq!((render_scale(1000, None), render_scale(500, None), render_scale(250, Some(4))), (1.0, 0.5, 0.25));
// 1/34 and 1/35 are both 29 thousandths: the comp's factor decides.
assert_eq!((render_scale(29, Some(35)), render_scale(29, Some(34))), (1.0 / 35.0, 1.0 / 34.0));
assert_eq!(render_scale(29, Some(2)), 1.0 / 34.0);
}
#[test]
fn converting_a_frame_with_nan_alpha_keeps_the_viewer_alive() {
let image = effectcraft_engine::render::Image::filled(1, 1, [0.5, 0.25, 0.1, f32::NAN]);
+2
View File
@@ -312,6 +312,8 @@ pub struct EffectcraftApp {
pub(crate) viewer_native: Option<(egui::TextureId, FrameKey, Arc<effectcraft_gpu::DisplayFrame>)>,
/// The texture the viewer draws and its frame key (CPU or GPU frame).
pub(crate) viewer_shown: Option<(egui::TextureId, FrameKey)>,
/// A Cryptomatte eyedropper probe is being built (one at a time, re-arming included).
pub(crate) crypto_probe_busy: Arc<std::sync::atomic::AtomicBool>,
/// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames fill it on
/// demand ([`EffectcraftApp::viewer_pixels`]).
pub(crate) viewer_image: Option<Arc<egui::ColorImage>>,
+1
View File
@@ -4,6 +4,7 @@ pub mod anim_tools;
pub mod camera_tracker_ui;
pub mod comp_settings;
pub mod content_fill_panel;
pub mod crypto_pick;
pub mod delete_items;
pub mod dialogs;
pub mod dialogs_3d;
+602
View File
@@ -0,0 +1,602 @@
//! The Cryptomatte eyedropper in the Composition viewer (#473). The button next to the effect's
//! Selection arms it ([`super::effect_controls::toggle_pick`] with [`KIND`]); while it is armed
//! the viewer names the ID under the cursor, and a click picks it into the Selection: a plain
//! click replaces it, Shift adds, Alt or Ctrl (Option or Cmd on a Mac) removes. It stays armed
//! for the next click until Esc or the button again, and highlights the hovered ID's coverage.
//! Arming it whenever the effect is selected (After Effects' behaviour, #1811) would only need
//! to set the same pick.
//!
//! The hover needs the frame's Cryptomatte probe ([`effectcraft_engine::Session::crypto_probe_job`]):
//! it is built off the UI thread, one at a time, not while playing or scrubbing; the chip says so
//! meanwhile, and clicks wait for it (the hover then builds nothing), except while playing, where
//! a click without its probe is not made. Once built, a hover is a few array reads.
//!
//! A click is the frame and the project shown at its press (and the resolution it read). It is
//! made at the end of a pass ([`upkeep`]), checked against the frame and the project right
//! before: if either changed since its press, it and every click waiting with it are dropped,
//! and the status says so. A press dragged further than [`SLOP`] picks nothing, and says so.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use effectcraft_engine::commands::CryptoProbeJob;
use effectcraft_engine::geom::Mat3;
use effectcraft_engine::project::Layer;
use effectcraft_engine::render::EvalCtx;
use egui::{Color32, Rect, Sense, vec2};
use serde_json::{Value, json};
use super::fx_widgets as fw;
use crate::EffectcraftApp;
use crate::i18n::{tr, tr_args};
use crate::state::{FxPick, Tool};
use crate::theme::Tokens;
/// [`FxPick::kind`] of the Cryptomatte eyedropper.
pub const KIND: &str = "crypto";
/// How long the time must stay put before a probe is built for it (scrubbing).
const SETTLE: f64 = 0.1;
/// A press that moves further than this (points) before its release picks nothing.
const SLOP: f32 = 24.0;
/// The status when waiting clicks are dropped.
const NOT_MADE: &str = "Pick not made: the frame or the project changed before its Cryptomatte IDs were read";
/// The status when a click is not made because the preview plays.
const PLAYING: &str = "Pick not made: stop playback to pick";
/// egui temp-data id for the eyedropper's [`State`] between frames.
const VIEWER_CRYPTO_PICK: &str = "viewer-crypto-pick";
/// egui temp-data id for the project revision and frame at the start of a pass.
const VIEWER_CRYPTO_PICK_START: &str = "viewer-crypto-pick-start";
fn pick_id() -> egui::Id {
egui::Id::unique(VIEWER_CRYPTO_PICK)
}
fn pick_start_id() -> egui::Id {
egui::Id::unique(VIEWER_CRYPTO_PICK_START)
}
/// The press of a click in progress.
#[derive(Clone)]
struct Press {
pos: egui::Pos2,
/// Its modifiers: letting go of Shift a moment early still adds.
keys: egui::Modifiers,
/// A menu or popup list was open: the click only closes it.
menu: bool,
/// A text field had the focus: the click commits it (the project may change by that).
typing: bool,
/// The pick query at the press (frame, resolution, layer point) and the project revision.
q: Option<Value>,
rev: u64,
}
/// The hovered ID's coverage highlight drawn over the viewer.
#[derive(Clone)]
struct HighlightMask {
key: (u32, Value),
texture: egui::TextureHandle,
size: [f64; 2],
uv: [f32; 2],
}
/// Hover and click state between frames.
#[derive(Clone, Default)]
struct State {
/// The last hover query (with the project revision) and its answer (or why there is none).
asked: Option<(Value, u64)>,
reply: Option<Result<Value, String>>,
/// Clicks waiting for their probe, in order, and the project revision they were made at.
pending: Vec<Value>,
pending_rev: u64,
/// The waiting clicks took the focus from a text field (the Selection being typed): they
/// are made once it has committed, on top of what it committed.
after_typing: bool,
/// A text field, a popup or a dialog had the keyboard the pass before: its Esc isn't the
/// pick's (egui lets go of the focus and closes popups, and Esc closes the dialog, before
/// the viewer runs).
esc_taken: bool,
/// The press of the click in progress.
press: Option<Press>,
/// Probes may be built for the waiting clicks (not while playing or scrubbing).
start: bool,
/// The comp time shown and since when (to wait for a scrub to settle).
shown: Option<(f64, f64)>,
/// The pick (layer, Selection) and the egui pass this state was last used for.
seen: Option<((u64, u64), u64)>,
/// The hovered ID's highlight: its hash and probe query, coverage texture, size and uv.
mask: Option<HighlightMask>,
}
/// Clears the busy flag and repaints when a build ends, a panic included.
struct Done(Arc<AtomicBool>, egui::Context);
impl Drop for Done {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
self.1.request_repaint();
}
}
fn run(job: CryptoProbeJob, done: Done) {
let _done = done;
job();
}
/// Whether the probe `q` needs is built; if not and `start`, start building it unless one is
/// being built (one at a time for the app). Asking changes nothing: only a build started here
/// supersedes another.
fn ready(app: &EffectcraftApp, ctx: &egui::Context, q: &Value, start: bool) -> Result<bool, String> {
let busy = &app.crypto_probe_busy;
if app.session.crypto_probe_ready(q).map_err(|e| shown_error(&e.to_string()))? {
return Ok(true);
}
// (Only this thread sets the flag, so it stays clear until the store.)
if !start || busy.load(Ordering::Acquire) {
return Ok(false);
}
let Some(job) = app.session.crypto_probe_job(q).map_err(|e| shown_error(&e.to_string()))? else { return Ok(true) };
busy.store(true, Ordering::Release);
let done = Done(busy.clone(), ctx.clone());
if cfg!(target_arch = "wasm32") {
run(job, done);
} else if std::thread::Builder::new().name("crypto-probe".into()).spawn(move || run(job, done)).is_err() {
return Err("could not start reading the Cryptomatte IDs".into());
}
Ok(false)
}
/// An engine error as the chip and the status line show it: without the command's "invalid
/// parameters for `…`:" prefix, capitalised.
fn shown_error(e: &str) -> String {
let e = e.strip_prefix("invalid parameters for `").and_then(|r| r.split_once("`: ")).map_or(e, |(_, m)| m);
let mut c = e.chars();
c.next().map_or_else(String::new, |f| f.to_uppercase().chain(c).collect())
}
/// The keys that remove an ID (Ctrl removes on a Mac too, but means right-click there).
pub fn remove_keys(os: egui::os::OperatingSystem) -> &'static str {
if os == egui::os::OperatingSystem::Mac { tr("Option or Cmd") } else { tr("Alt or Ctrl") }
}
/// At the start of every pass, before its keys: the project revision and frame on screen when
/// the pass's input happened. A press in the pass is taken against them, not against what the
/// pass's own keys made of them (a slow pass can hold a press, a PageDown and the release).
pub fn pass_start(app: &EffectcraftApp, ctx: &egui::Context) {
ctx.data_mut(|d| d.insert_temp(pick_start_id(), (app.session.revision, app.session.time().seconds())));
}
/// Every frame: a disarmed eyedropper lets go of its state and the session's probe.
pub fn upkeep(app: &mut EffectcraftApp, ctx: &egui::Context) {
let sid = pick_id();
// Another project (New, Open, Revert) ends a pick: it is kept by layer id and property uid,
// which the next project reuses.
let eid = egui::Id::unique("viewer-fx-pick-project");
let epoch = app.session.script_ui.project_epoch;
let seen = ctx.data(|d| d.get_temp::<u64>(eid));
if seen != Some(epoch) {
if seen.is_some() && app.ui.fx_pick.is_some() {
app.ui.fx_pick = None;
app.ui.status.clear();
}
ctx.data_mut(|d| d.insert_temp(eid, epoch));
}
if app.ui.fx_pick.as_ref().is_none_or(|p| p.kind != KIND) && ctx.data(|d| d.get_temp::<State>(sid)).is_some() {
ctx.data_mut(|d| d.remove::<State>(sid));
app.session.drop_crypto_probe();
}
// The clicks waiting in the viewer, after everything else this pass did (its keys, edits and
// frame steps included).
if let Some(mut st) = ctx.data(|d| d.get_temp::<State>(sid)).filter(|s| s.seen.is_some_and(|(_, n)| n == ctx.cumulative_pass_nr())) {
make_waiting(app, ctx, &mut st);
ctx.data_mut(|d| d.insert_temp(sid, st));
}
}
/// Drop every waiting click, and say so.
fn drop_waiting(app: &mut EffectcraftApp, ctx: &egui::Context, st: &mut State) {
(st.pending, st.after_typing) = (vec![], false);
app.say(ctx, tr(if app.playback.playing { PLAYING } else { NOT_MADE }).to_string());
}
/// Make one click. Our own pick is not "the project changed" for a click still held.
fn make(app: &mut EffectcraftApp, ctx: &egui::Context, st: &mut State, q: Value) -> Result<Value, String> {
let before = app.session.revision;
let r = crate::menus::invoke(app, ctx, "effect.pickCryptomatte", q);
if let Some(p) = st.press.as_mut()
&& p.rev == before
{
p.rev = app.session.revision;
}
r
}
/// Make the waiting clicks whose probe is built, in order. Each is checked against the frame and
/// the project right before it is made, with nothing in between: if either changed since its
/// press, it and the clicks waiting with it are not made.
fn make_waiting(app: &mut EffectcraftApp, ctx: &egui::Context, st: &mut State) {
// A click that took the focus from a text field waits for the field to commit (it may do so
// later in this pass or in the next), then is made on what it committed.
let typing = st.after_typing && ctx.text_edit_focused();
if st.after_typing && !typing {
(st.after_typing, st.pending_rev) = (false, app.session.revision);
}
while let Some(q) = st.pending.first().cloned().filter(|_| !typing) {
let comp = app.session.active_comp_id().map(|c| c.0);
if st.pending_rev != app.session.revision || q["comp"].as_u64() != comp || q["time"].as_f64() != Some(app.session.time().seconds()) {
return drop_waiting(app, ctx, st);
}
match ready(app, ctx, &q, st.start) {
Ok(true) => match make(app, ctx, st, q) {
Ok(r) if r["pending"] == true => break,
r => {
st.pending.remove(0);
(st.asked, st.pending_rev) = (None, app.session.revision);
let msg = match r {
Ok(r) => tr_args("Cryptomatte Selection: {}", &[&r["selection"].as_str().unwrap_or_default()]),
Err(e) => shown_error(&e),
};
app.say(ctx, msg);
}
},
Ok(false) => break,
Err(e) => {
(st.pending, st.after_typing) = (vec![], false);
app.say(ctx, e);
}
}
}
}
/// Middle-button pan and forwarded right-click for the armed eyedropper overlay.
fn viewer_pan_and_secondary(app: &mut EffectcraftApp, ctx: &egui::Context, resp: &egui::Response) {
// The middle button pans the viewer, as the Hand tool (and as without the eyedropper).
if resp.dragged_by(egui::PointerButton::Middle) {
let pid = egui::Id::unique("viewer-fx-pick-pan");
if resp.drag_started() {
let start = app.ui.viewer.pan;
ctx.data_mut(|d| d.insert_temp(pid, start));
}
if let (Some(start), Some(d)) = (ctx.data(|m| m.get_temp::<[f32; 2]>(pid)), resp.total_drag_delta()) {
app.ui.viewer.pan = [start[0] + d.x, start[1] + d.y];
}
}
// The right button opens the viewer's context menu (next pass).
if resp.secondary_clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(super::viewer::FORWARDED_SECONDARY), p));
}
}
/// Whether Space / Hand / Zoom (`tools`) or middle/right buttons (`nav`) own the pointer.
struct PointerNav {
tools: bool,
nav: bool,
}
/// Queues Cryptomatte picks from primary presses and releases on the armed eyedropper overlay.
///
/// A press is taken against the pass-start revision and frame, then queued for [`make_waiting`]
/// (or dropped if the drag, playback, frame or project make it invalid). `nav` suppresses a new
/// press when another pointer button or tool owns the click.
fn viewer_click_to_pick(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
map: &super::viewer::ViewerMap,
ectx: &EvalCtx,
st: &mut State,
pointer: PointerNav,
at: &dyn Fn(egui::Pos2) -> Option<Value>,
) {
let ctx = ui.ctx().clone();
// The click is what the press saw: its frame, resolution, point and project. The pass's
// primary presses and releases are taken in order: a slow pass can hold a whole click, or one
// click's release and the next one's press (egui then reports no press on the overlay).
// `None`: the window lost the focus, so a held press may never see its release.
let events: Vec<Option<(egui::Pos2, bool, egui::Modifiers)>> = ui.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers } => Some(Some((*pos, *pressed, *modifiers))),
egui::Event::WindowFocused(false) => Some(None),
_ => None,
})
.collect()
});
// Space (or the Hand or Zoom tool) while the button is held cancels the press (a pan needs a
// new press).
if pointer.tools {
st.press = None;
}
let (rev0, t0) = ctx.data(|d| d.get_temp::<(u64, f64)>(pick_start_id())).unwrap_or((app.session.revision, ectx.time.seconds()));
for e in events {
let Some((pos, pressed, keys)) = e else {
st.press = None;
continue;
};
if pressed {
let ours = map.area.contains(pos) && ctx.layer_id_at(pos) == Some(ui.layer_id());
st.press = (!pointer.nav && ours).then(|| Press {
pos,
keys,
menu: crate::widgets::any_menu_open(&ctx),
typing: ctx.text_edit_focused(),
// The frame on screen at the press: a frame step earlier in this pass drops it.
q: at(pos).map(|mut q| {
if t0 != ectx.time.seconds() {
q["time"] = json!(t0);
}
q
}),
rev: rev0,
});
continue;
}
// The release ends the press, whatever egui made of it.
let Some(Press { pos: from, keys: k, menu: false, typing, q: Some(mut q), rev }) = st.press.take() else { continue };
// Ctrl too on a Mac, where `command` is Cmd.
q["mode"] = json!(if k.alt || k.command || k.ctrl {
"remove"
} else if k.shift {
"add"
} else {
"replace"
});
let same_frame = q["time"].as_f64() == Some(ectx.time.seconds());
if pos.distance(from) > SLOP {
app.say(&ctx, tr("Drag cancelled the pick").to_string());
} else if app.playback.playing && !(same_frame && app.session.crypto_probe_ready(&q).unwrap_or(false)) {
// Playing, a frame without its IDs read is gone before they could be: not queued.
app.say(&ctx, tr(PLAYING).to_string());
} else if !same_frame || !typing && rev != app.session.revision {
// A frame step or an edit came between the press and the release.
drop_waiting(app, &ctx, st);
} else {
if st.pending.is_empty() {
st.pending_rev = rev;
}
st.after_typing |= typing || ctx.text_edit_focused();
st.pending.push(q);
}
}
}
/// Drop stale hover/click state when the pick or pass gap means it no longer applies.
fn reset_stale_state(app: &mut EffectcraftApp, ctx: &egui::Context, st: &mut State, pick: &FxPick) {
let pass = ctx.cumulative_pass_nr();
// Disarmed (or the viewer hidden) since, or another pick: start afresh, so an old click
// waiting for its probe is never made. (A probe still being built keeps the app's flag.)
if st.seen.is_none_or(|(k, n)| k != (pick.layer, pick.prop) || n + 1 < pass) {
if !st.pending.is_empty() {
app.say(ctx, tr(NOT_MADE).to_string());
}
*st = State::default();
}
st.seen = Some(((pick.layer, pick.prop), pass));
}
/// Track the shown time for scrub settle; returns whether probe builds may start.
fn track_scrub_settle(app: &EffectcraftApp, ctx: &egui::Context, st: &mut State, time_secs: f64) -> bool {
// No builds while playing, nor until a scrub has settled.
let now = ctx.input(|i| i.time);
if st.shown.is_none_or(|s| s.0 != time_secs) {
st.shown = Some((time_secs, if st.shown.is_some() { now } else { f64::MIN }));
}
let settled = st.shown.is_some_and(|s| now - s.1 >= SETTLE);
if !settled {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(SETTLE));
}
let start = settled && !app.playback.playing;
st.start = start;
start
}
/// Read the Cryptomatte ID under the cursor; returns whether a probe is still being read.
fn hover_read(
app: &mut EffectcraftApp,
ctx: &egui::Context,
map: &super::viewer::ViewerMap,
st: &mut State,
resp: &egui::Response,
start: bool,
at: &dyn Fn(egui::Pos2) -> Option<Value>,
) -> bool {
let mut reading = false;
match resp.hover_pos() {
Some(hp) => {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
let c = map.to_comp(hp);
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
let q = at(hp);
let asked = q.clone().map(|q| (q, app.session.revision));
if asked != st.asked {
st.reply = None;
// While clicks wait, they own the build: the hover only reads.
match q.as_ref().map(|q| ready(app, ctx, q, start && st.pending.is_empty())) {
Some(Ok(true)) => {
let r = q.map(|q| app.session.execute("effect.cryptomatteAt", q).map_err(|e| shown_error(&e.to_string())));
if r.as_ref().is_some_and(|r| r.as_ref().is_ok_and(|v| v["pending"] == true)) {
reading = true;
} else {
st.reply = r.filter(|r| !matches!(r, Ok(Value::Null)));
st.asked = asked;
}
}
Some(Ok(false)) => reading = true,
Some(Err(e)) => (st.reply, st.asked) = (Some(Err(e)), asked),
None => st.asked = None,
}
}
}
None => (st.asked, st.reply) = (None, None),
}
reading
}
/// Rebuilds the texture when the hovered ID, or the layer, effect, scale, frame or project
/// revision it was read at, changes; then draws it.
fn draw_coverage_highlight(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, map: &super::viewer::ViewerMap, m: &Mat3, st: &mut State) {
let hovered = st.reply.as_ref().and_then(|r| u32::from_str_radix(r.as_ref().ok()?["hash"].as_str()?, 16).ok());
let hovered = hovered.zip(st.asked.as_ref()).map(|(h, (q, rev))| (h, json!([q["layer"], q["effect"], q["scale"], q["time"], rev])));
if let (Some(h), Some(q)) = (&hovered, st.asked.as_ref().map(|a| &a.0))
&& st.mask.as_ref().is_none_or(|mask| mask.key != *h)
{
st.mask = app.session.crypto_coverage(q, h.0, 1024).map(|c| {
let a = app.tokens.accent;
let px = c.coverage.iter().map(|v| Color32::from_rgba_unmultiplied(a.r(), a.g(), a.b(), (v * 140.0) as u8)).collect();
let img = egui::ColorImage::new([c.width, c.height], px);
let size = [c.size[0], c.size[1]];
HighlightMask {
key: h.clone(),
texture: ctx.load_texture("crypto-pick-highlight", img, egui::TextureOptions::NEAREST),
size,
uv: [c.uv[0] as f32, c.uv[1] as f32],
}
});
}
if let (Some(h), Some(mask)) = (&hovered, &st.mask)
&& h == &mask.key
{
let (tex, size, uv) = (&mask.texture, mask.size, mask.uv);
let corners = [[0.0, 0.0], [size[0], 0.0], size, [0.0, size[1]]].map(|p| map.to_screen(fw::layer_to_comp(m, p)));
let mut mesh = egui::Mesh::with_texture(tex.id());
for (pos, t) in corners.iter().zip([[0.0, 0.0], [uv[0], 0.0], uv, [0.0, uv[1]]]) {
mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv: egui::pos2(t[0], t[1]), color: Color32::WHITE });
}
mesh.indices = vec![0, 1, 2, 0, 2, 3];
painter.add(mesh);
// Its corners (screen points), for tests and agents.
let label = corners.iter().map(|p| format!("{:.2},{:.2}", p.x, p.y)).collect::<Vec<_>>().join(" ");
app.auto.add("viewer.cryptoPick.highlight", Rect::from_points(&corners), &label);
}
}
/// Draw the status chip (hovered name, reading, or usage hint).
fn draw_status_chip(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, map: &super::viewer::ViewerMap, st: &State, reading: bool) {
let err = !reading && matches!(st.reply, Some(Err(_)));
let text = match &st.reply {
_ if reading && app.playback.playing => tr("Stop playback to pick").to_string(),
_ if reading => tr("Reading Cryptomatte IDs…").to_string(),
Some(Ok(r)) => {
let name = r["name"].as_str().map_or_else(|| format!("<{}>", r["hash"].as_str().unwrap_or_default()), str::to_string);
if r["selected"] == true { tr_args("{} (selected)", &[&name]) } else { name }
}
Some(Err(e)) => e.clone(),
None => tr_args("Click: replace · Shift: add · {}: remove", &[&remove_keys(ctx.os())]),
};
// One line, at most the viewer's width (long names end in an ellipsis); an error in red, on
// as many lines as it needs (at least three, more in a narrow viewer), so its hint shows.
let max = (map.area.width() - 44.0).clamp(40.0, 480.0);
let rows = ((map.area.height() - 28.0) / 14.0).max(3.0) as usize;
let colour = if err { app.tokens.danger } else { app.tokens.text };
let mut job = egui::text::LayoutJob::simple_singleline(text.clone(), Tokens::ui(11.5), colour);
job.wrap = egui::text::TextWrapping { max_width: max, max_rows: if err { rows } else { 1 }, break_anywhere: !err, overflow_character: Some('…') };
let galley = painter.layout_job(job);
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(galley.size().x + 24.0, (galley.size().y + 8.0).max(22.0)));
app.auto.add("viewer.cryptoPick", chip, &text);
if err {
app.auto.add("viewer.cryptoPick.error", chip, if galley.elided { "cut" } else { "whole" });
painter.rect(chip, 6.0, Color32::from_rgba_unmultiplied(48, 10, 8, 220), egui::Stroke::new(1.0, colour), egui::StrokeKind::Inside);
} else {
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
}
painter.galley(chip.left_center() + vec2(12.0, -galley.size().y / 2.0), galley, colour);
}
/// Esc disarms the eyedropper only when no text field, popup, or dialog had the keyboard this
/// pass or the pass before (egui lets go of the focus and closes popups, and Esc closes the
/// dialog, before the viewer runs).
fn handle_escape(app: &mut EffectcraftApp, ui: &mut egui::Ui, st: &mut State) {
let ctx = ui.ctx();
// (Esc in a text field, the Selection being typed, only ends the typing; in a menu or dialog, it.)
let esc_taken = std::mem::replace(&mut st.esc_taken, ctx.text_edit_focused() || egui::Popup::is_any_open(ctx) || app.dialog.is_some());
if ui.input(|i| i.key_pressed(egui::Key::Escape)) && !esc_taken && !st.esc_taken {
app.ui.fx_pick = None;
app.ui.status.clear();
st.pending.clear();
}
}
/// The armed eyedropper's part of the viewer. `m` maps the layer's effect space to the comp.
#[allow(clippy::too_many_arguments)]
pub fn viewer_hook(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &super::viewer::ViewerMap,
ectx: &EvalCtx,
layer: &Layer,
m: &Mat3,
pick: &FxPick,
) {
let ctx = ui.ctx().clone();
let sid = pick_id();
let mut st: State = ctx.data(|d| d.get_temp(sid)).unwrap_or_default();
reset_stale_state(app, &ctx, &mut st, pick);
// The Cryptomatte effect that owns the Selection (gone: the pick ends).
let Some(fx) = layer.effects().and_then(|f| f.groups().find(|g| g.find(pick.prop).is_some())).map(|g| g.uid) else {
app.ui.fx_pick = None;
return;
};
let start = track_scrub_settle(app, &ctx, &mut st, ectx.time.seconds());
// The resolution of the frame on screen: the pick reads the pixel it shows.
let scale = app
.viewer_shown
.as_ref()
.filter(|(_, k)| k.comp == ectx.comp_id.0)
.map_or(1.0, |(_, k)| crate::frames::render_scale(k.scale, ectx.comp.resolution.factor()));
// The centre of the shown frame's pixel under the cursor: the layer may be resampled into
// the frame (a proxy, a scaled layer), and that pixel is what the user sees.
let region = super::viewer_tools::shown_region(&ctx, map.comp);
let at = |pos: egui::Pos2| {
let c = map.to_comp(pos);
let snap = |v: f64, o: f64| o + (((v - o) * scale).floor() + 0.5) / scale;
let p = fw::comp_to_layer(m, [snap(c[0], region[0]), snap(c[1], region[1])])?;
let (x, y, time) = (p[0], p[1], ectx.time.seconds());
Some(
json!({"comp": ectx.comp_id.0, "layer": layer.id.0, "effect": fx, "x": x, "y": y, "scale": scale.clamp(1e-3, 1.0), "time": time, "probeOnly": true}),
)
};
// The Hand and Zoom tools (Space held too) and other buttons keep their own clicks and drags.
let tools = super::space_hand(&ctx) || matches!(app.ui.tool, Tool::Hand | Tool::Zoom);
let nav = tools || ctx.input(|i| i.pointer.middle_down() || i.pointer.secondary_down());
// Drags too: a click that drifts must not move the layer under it. (egui hands a press to
// what sensed it the pass before, so middle and right presses land here: passed on below.)
let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), if tools { Sense::hover() } else { Sense::click_and_drag() });
viewer_pan_and_secondary(app, &ctx, &resp);
app.auto.add("viewer.fxPick", map.area, &pick.name);
let mut reading = hover_read(app, &ctx, map, &mut st, &resp, start, &at);
viewer_click_to_pick(app, ui, map, ectx, &mut st, PointerNav { tools, nav }, &at);
// Clicks waiting for their probe ([`make_waiting`], at the end of the pass).
reading |= !st.pending.is_empty();
draw_coverage_highlight(app, &ctx, painter, map, m, &mut st);
draw_status_chip(app, &ctx, painter, map, &st, reading);
handle_escape(app, ui, &mut st);
ctx.data_mut(|d| d.insert_temp(sid, st));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors_show_without_the_command_prefix() {
let e = "invalid parameters for `effect.cryptomatteAt`: this layer has no pixels";
assert_eq!(shown_error(e), "This layer has no pixels");
assert_eq!(shown_error("Reading the Cryptomatte IDs failed: x"), "Reading the Cryptomatte IDs failed: x");
}
/// A build that panics still clears the busy flag, so the next hover can build again.
#[test]
fn a_panicking_build_clears_busy() {
let busy = Arc::new(AtomicBool::new(true));
let done = Done(busy.clone(), egui::Context::default());
let r = std::thread::spawn(move || run(Box::new(|| panic!("a broken build")), done)).join();
assert!(r.is_err() && !busy.load(Ordering::Acquire));
}
}
@@ -1,7 +1,7 @@
use super::common::{Actions, DIAL_ROW, PARAM_NAME_X, ROW, TREE_INDENT, gesture_key};
use super::{
Align2, Color32, EffectcraftApp, EvalCtx, FxPick, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw, icons, json,
pos2, vec2, widgets,
Align2, Color32, EffectcraftApp, EvalCtx, FxPick, GroupKind, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw,
icons, json, pos2, vec2, widgets,
};
fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) {
@@ -65,6 +65,9 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p
app.ui.text_color_pick = None;
app.ui.status = if kind == "point" {
format!("Click in the Composition panel to set {}", prop.name)
} else if kind == crate::panels::crypto_pick::KIND {
let keys = crate::panels::crypto_pick::remove_keys(egui::os::OperatingSystem::from_target_os());
crate::i18n::tr_args("Click IDs in the Composition panel: Shift adds, {} removes, Esc ends", &[&keys])
} else {
format!("Click in the Composition panel to sample {}", prop.name)
};
@@ -485,11 +488,27 @@ fn value_text(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &
let Value::Str(s) = value else {
return;
};
let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx;
let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0));
let PropCx { t, uid, cy, vx, r, layer, group, prop, .. } = *cx;
// Cryptomatte's Selection also has an eyedropper that picks IDs in the viewer (#473).
let crypto = matches!(&group.kind, GroupKind::Effect { effect } if effect == "ec.3d.cryptomatte") && prop.match_id == "selection";
let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - if crypto { 36.0 } else { 12.0 }).clamp(80.0, 320.0), 18.0));
if crypto {
let er = Rect::from_center_size(pos2(fr.max.x + 14.0, cy), vec2(18.0, 18.0));
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == crate::panels::crypto_pick::KIND);
if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t)
.on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
.clicked()
{
toggle_pick(app, crate::panels::crypto_pick::KIND, layer, prop);
}
app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name);
}
let bid = egui::Id::unique(("ec-text", uid));
let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.to_owned());
let resp = widgets::text_field(ui, fr, &mut buf, "", &t);
// Cut off by a narrow panel (a long Selection): the whole text on hover.
let cut = !resp.has_focus() && ui.painter().layout_no_wrap(buf.clone(), egui::TextStyle::Body.resolve(ui.style()), t.text).size().x > fr.width() - 17.0;
let resp = if cut { resp.on_hover_text(buf.clone()) } else { resp };
// The field has no visible hint: give the screen reader the parameter's name.
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name));
app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name);
@@ -221,9 +221,19 @@ pub fn viewer_hook(
let ctx = ui.ctx().clone();
crate::color_picker::note_viewer(&ctx, map.area);
let mut actions: Actions = vec![];
let point_preview = match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) {
ControlFlow::Break(()) => return,
ControlFlow::Continue(p) => p,
let point_preview = if let Some(pick) = app.ui.fx_pick.clone().filter(|p| p.kind == crate::panels::crypto_pick::KIND) {
match ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)) {
Some(l) => {
crate::panels::crypto_pick::viewer_hook(app, ui, painter, map, ectx, l, &crate::panels::viewer::from_effect_space(ectx, l, l2c(ectx, l)), &pick)
}
None => app.ui.fx_pick = None,
}
None
} else {
match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) {
ControlFlow::Break(()) => return,
ControlFlow::Continue(p) => p,
}
};
text_color_pick(app, ui, painter, map, ectx, &mut actions);
popup_eyedropper(app, ui, painter, map);
+10 -4
View File
@@ -63,6 +63,10 @@ fn map_id() -> egui::Id {
egui::Id::unique("viewer-map")
}
/// The egui data key (an `egui::Pos2`) of a right-click on the viewer that an overlay sensed:
/// the viewer opens its context menu there on the next pass.
pub(crate) const FORWARDED_SECONDARY: &str = "viewer-forwarded-secondary";
pub fn comp_to_screen(ctx: &egui::Context, p: [f32; 2]) -> Option<Pos2> {
let m: ViewerMap = ctx.data(|d| d.get_temp(map_id()))?;
Some(m.to_screen([p[0] as f64, p[1] as f64]))
@@ -828,9 +832,9 @@ fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m
// Interaction.
let resp = ui.interact(area, egui::Id::unique("viewer-interact"), Sense::click_and_drag());
let menu_hits_id = egui::Id::unique(("viewer-menu-hits", cid.0));
if resp.secondary_clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
// A right-click an overlay took (the Cryptomatte eyedropper's) opens the menu too.
let forwarded = ui.data_mut(|d| d.remove_temp::<egui::Pos2>(egui::Id::unique(FORWARDED_SECONDARY)));
if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.secondary_clicked()).or(forwarded) {
// Picking an already selected layer preserves the group; an unselected hit
// becomes the target, using the same visible/unlocked hit test as left-click.
pick(app, ectx, map.to_comp(pos), false);
@@ -842,7 +846,9 @@ fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m
app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect();
let mut context_actions = Vec::new();
let copy_frame = crate::i18n::label(app, "comp.copyFrame", "Copy Frame to Clipboard");
crate::widgets::context_menu(&resp, |ui| {
let menu = egui::Popup::context_menu(&resp).style(crate::widgets::menu_style);
let open = forwarded.map(|_| egui::containers::SetOpenCommand::Bool(true));
if open.is_some() { menu.open_memory(open) } else { menu }.show(|ui| {
let hits = ui.data(|d| d.get_temp::<Vec<(u64, String)>>(menu_hits_id)).unwrap_or_default();
if !hits.is_empty() {
ui.menu_button(crate::i18n::tr("Select"), |ui| {
+2
View File
@@ -23,6 +23,8 @@ mod ui_color_hex;
mod ui_comp_presets;
mod ui_contrast;
mod ui_control_modifiers;
mod ui_crypto_pick;
mod ui_crypto_pick_undo;
mod ui_dialog_preview;
mod ui_dock_resize;
mod ui_drag_drop;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -143,7 +143,9 @@ revolutions are `timeline.prop.<uid>.revolutions`, `effectControls.prop.<uid>.re
`viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.<menu>Item.<n>`),
`viewer.ruler.top|left|origin`, `viewer.mask.<uid>.vertex.<i>`, `viewer.shapePath.<uid>.vertex.<i>`,
`viewer.motionPath.<layer>.<key>[.in|.out]`, `viewer.puppetPin.<uid>[.rotate|.scale]`,
`viewer.freeTransform.handle.<i>` and
`viewer.freeTransform.handle.<i>`, `viewer.cryptoPick` (the Cryptomatte eyedropper's hover chip; its
label is the chip's text), `viewer.cryptoPick.error` (the chip when it shows an error; label `whole`, or
`cut` when the message is cut off), `viewer.cryptoPick.highlight` (the hovered ID's highlighted area) and
`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.<i>]`,
`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen.
+1 -1
View File
@@ -26,7 +26,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Category | Effects | On the GPU | Known differences or bugs |
|---|---:|---:|---|
| 3D Channel | 8 | 8 | Cryptomatte selection can't be set in the UI and the GPU path is slow (#473; quoted names with commas fixed); multi-layer EXR decode shared (#474 open: f16 storage and cache sizing remain) |
| 3D Channel | 8 | 8 | Cryptomatte's GPU path is slow (#473; the Selection is picked in the viewer with an eyedropper, quoted names with commas fixed); multi-layer EXR decode shared (#474 open: f16 storage and cache sizing remain) |
| Audio | 13 | 0 | CPU by design (applied in the mixdown) |
| Blur & Sharpen | 16 | 15 | Radial Blur keeps the layer edges as After Effects 2026 (713 now pixel-identical); Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) |
| Channel | 13 | 13 | — |
+4
View File
@@ -120,6 +120,10 @@ Nuke's `beauty`) show the beauty pass: the layer named Combined, Beauty, Image,
Color, else the first colour layer that isn't a data pass. Cryptomatte, depth, mist, normals,
vectors, positions and IDs are never the picture. EXtractoR reads every other layer and channel.
The Cryptomatte effect keys objects by their Cryptomatte ID passes, with the names from the
file's manifest. Pick them with the eyedropper next to its Selection, then click in the
Composition viewer (Shift adds, Alt or Ctrl removes).
## Replacing and reloading
Importing a layered file as a composition refreshes the decoded sources used by its layers,
+1 -1
View File
@@ -65,7 +65,7 @@ are grouped by area; the issue tracker has the rest.
| 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) |
| 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) |
| 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) |
| 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) |
| 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; the viewer eyedropper added; GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) |
| 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) |
| 11 | **Text rendering and layout** | #453 CJK text in comps: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) |
| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) |