mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 02:20:50 +00:00
Merge PR #2957
This commit is contained in:
commit
f5e81cd1f9
22 files changed
+4109
-22
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user