Merge remote-tracking branch 'origin/main' into jp/ja-dialogs

# Conflicts:
#	crates/ui-egui/src/i18n/ui.rs
This commit is contained in:
midasdf
2026-10-24 10:14:41 +09:00
87 changed files with 4410 additions and 343 deletions
+10 -2
View File
@@ -6,6 +6,7 @@
//! effectcraft-cli exec <command-id> [--params JSON] run one command
//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`)
//! effectcraft-cli run <id> <json> [<id> <json> …] run several commands in order
//! effectcraft-cli run --args-file F the same, one argument per line of F
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
@@ -96,6 +97,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
commands [--filter TEXT] [--enabled] list engine commands
exec <command-id> [--params JSON] run one engine command (exec --list: list them, as `commands`)
run <id> <json> [<id> <json> ...] run several commands in order
run --args-file F the same, one argument per line of F (no command-line length limit)
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
get <comp> <layer> <path> [--time S] read a property
set <comp> <layer> <path> <value> [--time S] [--expression E]
@@ -148,6 +150,7 @@ const VALUED: &[&str] = &[
"--expression",
"--depth",
"--eval",
"--args-file",
// render
"--template",
"--values",
@@ -515,10 +518,15 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&with_saved(r, saved), json_out);
}
"run" => {
if args.pos.is_empty() {
let mut pairs = args.pos.clone();
// Windows caps a command line at 32 767 characters: long batches come from a file.
if let Some(f) = args.opt("--args-file") {
let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?;
pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from));
}
if pairs.is_empty() {
return usage_err("run needs <command-id> [<json>] pairs");
}
let pairs = args.pos.clone();
let mut b = backend(args, true)?;
let mut results = vec![];
let mut i = 0;
+23
View File
@@ -110,6 +110,29 @@ fn exec_set_get_with_saved_project() {
assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}");
}
/// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer
/// than a Windows command line (32 767 characters, os error 206) still runs.
#[test]
fn run_reads_arguments_from_a_file() {
let proj = tmp("args-file.ecproj");
let p = proj.to_str().unwrap();
let mut lines = vec!["comp.new".to_string(), json!({"name": "A", "width": 64, "height": 64}).to_string()];
for i in 0..400 {
lines.push("layer.newSolid".into());
lines.push(json!({"width": 8, "height": 8, "name": format!("Solid with a long name to pass the limit {i:04}")}).to_string());
lines.push(String::new());
}
let text = lines.join("\n");
assert!(text.len() > 32_767);
let file = tmp("args-file.txt");
std::fs::write(&file, text).unwrap();
let v = ok_json(&["run", "--args-file", file.to_str().unwrap(), "--empty", "--save-as", p]);
assert_eq!(v["result"].as_array().map(Vec::len), Some(401), "{v}");
let out = bin().args(["run", "--args-file", "no/such/file.txt", "--empty"]).output().unwrap();
assert_eq!(out.status.code(), Some(2));
assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt"));
}
#[test]
fn render_frame_writes_png() {
let out = tmp("f.png");
+12 -4
View File
@@ -37,16 +37,24 @@ fn index_map(b: &Buf, a: &AuxChannels) -> Vec<Option<usize>> {
/// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1).
fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec<Option<usize>> {
let (w, h) = (b.img.width as usize, b.img.height as usize);
let inv = 1.0 / b.scale.max(1e-9);
(0..w * h)
.into_par_iter()
.map(|i| {
let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]);
a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv)
let [x, y] = aux_point(b.scale, b.offset, [(i % w) as f64 + at[0], (i / w) as f64 + at[1]]);
a.index_at(x, y)
})
.collect()
}
/// The layer point where 3D Channel effects look up the aux for buffer position `p` (pixel
/// `i` + sub-pixel offset) of a buffer with `scale` and `offset`. The Cryptomatte pick calls
/// it too, so the two can't pick different aux pixels.
#[inline]
pub fn aux_point(scale: f64, offset: [f64; 2], p: [f64; 2]) -> [f64; 2] {
let inv = 1.0 / scale.max(1e-9);
[(p[0] - offset[0]) * inv, (p[1] - offset[1]) * inv]
}
/// A channel sampled into the buffer grid (`fill` outside).
fn plane_of(b: &Buf, idx: &[Option<usize>], ch: &[f32], fill: f32) -> Plane {
Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() }
@@ -326,7 +334,7 @@ fn wild(pat: &str, name: &str) -> bool {
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
fn selection_names(sel: &str) -> Vec<String> {
pub(crate) fn selection_names(sel: &str) -> Vec<String> {
let mut out = vec![];
let mut cur = String::new();
let mut quote: Option<char> = None;
+137 -3
View File
@@ -55,6 +55,17 @@ impl Curve {
/// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or
/// fewer than two valid points.
pub fn parse(s: &str) -> Option<Curve> {
Self::parse_with(s, false)
}
/// [`Curve::parse`] interpolated as After Effects' Curves effect does: a natural cubic spline
/// through the points, clamped to 0..1 (AE 2026: RGB points (0,0) (125,199) (255,255) and
/// Red (0,0) (128,44) (255,255) map every grey step exactly as the natural spline does).
pub fn parse_natural(s: &str) -> Option<Curve> {
Self::parse_with(s, true)
}
fn parse_with(s: &str, natural: bool) -> Option<Curve> {
let mut pts: Vec<(f32, f32)> = s
.split(|c: char| c.is_whitespace() || c == ';')
.filter(|t| !t.is_empty())
@@ -74,6 +85,12 @@ impl Curve {
let ys: Vec<f32> = pts.iter().map(|p| p.1).collect();
let n = xs.len();
let d: Vec<f32> = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect();
if natural {
let ms = natural_slopes(&xs, &d);
let mut c = Curve { xs, ys, ms, lut: Vec::new() };
c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32).clamp(0.0, 1.0)).collect();
return Some(c);
}
let mut ms = vec![0.0f32; n];
ms[0] = d[0];
ms[n - 1] = d[n - 2];
@@ -136,6 +153,40 @@ impl Curve {
}
}
/// Knot slopes of the natural cubic spline (zero curvature at both ends) through points with
/// abscissae `xs` and segment slopes `d`; as Hermite tangents they reproduce the spline exactly.
fn natural_slopes(xs: &[f32], d: &[f32]) -> Vec<f32> {
let n = xs.len();
if n < 3 {
return vec![d.first().copied().unwrap_or(0.0); n];
}
let h: Vec<f64> = (0..n - 1).map(|k| f64::from(xs[k + 1] - xs[k])).collect();
let dd: Vec<f64> = d.iter().map(|&v| f64::from(v)).collect();
// Second derivatives M[1..n-1] (M[0] = M[n-1] = 0): tridiagonal system, Thomas algorithm.
let m = n - 2;
let (mut a, mut b, mut c, mut r) = (vec![0.0; m], vec![0.0; m], vec![0.0; m], vec![0.0; m]);
for i in 0..m {
let k = i + 1;
a[i] = h[k - 1];
b[i] = 2.0 * (h[k - 1] + h[k]);
c[i] = h[k];
r[i] = 6.0 * (dd[k] - dd[k - 1]);
}
for i in 1..m {
let w = a[i] / b[i - 1];
b[i] -= w * c[i - 1];
r[i] -= w * r[i - 1];
}
let mut sec = vec![0.0f64; n];
for i in (0..m).rev() {
let next = if i + 1 < m { sec[i + 2] } else { 0.0 };
sec[i + 1] = (r[i] - c[i] * next) / b[i];
}
let mut ms: Vec<f32> = (0..n - 1).map(|k| (dd[k] - h[k] * (2.0 * sec[k] + sec[k + 1]) / 6.0) as f32).collect();
ms.push((dd[n - 2] + h[n - 2] * (sec[n - 2] + 2.0 * sec[n - 1]) / 6.0) as f32);
ms
}
fn ev(c: &Option<Curve>, v: f32) -> f32 {
c.as_ref().map(|c| c.eval(v)).unwrap_or(v)
}
@@ -145,7 +196,7 @@ fn ev(c: &Option<Curve>, v: f32) -> f32 {
/// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the
/// master RGB curve first, then their own curve.
fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf {
let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id)));
let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse_natural(ctx.params.s(id)));
if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() {
return b;
}
@@ -201,7 +252,7 @@ impl Lv {
if clip_w {
t = t.min(1.0);
}
self.ob + (self.ow - self.ob) * t.max(0.0).powf(1.0 / self.g.max(0.01))
self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01))
}
}
@@ -217,7 +268,10 @@ fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf {
return b;
}
let clip = crate::color_fx::levels_clip(ctx);
map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0)));
// Each channel's own controls first, then the RGB controls: After Effects 2026 with RGB
// (0.1, 0.9, gamma 1.5, 0.05, 0.95) and Green Input White 0.8 maps grey 32/255 to green
// 52/255 (RGB first would give 45).
map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[0].apply(lv[i + 1].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0)));
b
}
@@ -1139,6 +1193,86 @@ mod tests {
assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6);
}
/// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1,
/// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green
/// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps:
/// the channel controls run first, nothing clips (8-bit captures clamp the results).
#[test]
fn levels_ic_matches_after_effects_in_32_bpc() {
let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255];
let ae = [
[0, 0, 70],
[0, 0, 88],
[0, 52, 104],
[61, 108, 132],
[102, 150, 158],
[138, 187, 181],
[171, 220, 203],
[203, 250, 223],
[233, 255, 243],
[255, 255, 255],
];
let mut img = Image::new(10, 1);
for (x, v) in steps.iter().enumerate() {
let v = *v as f32 / 255.0;
img.set(x as u32, 0, [v, v, v, 1.0]);
}
let vals = [
("rgb/rgbInBlack", Value::Scalar(0.1)),
("rgb/rgbInWhite", Value::Scalar(0.9)),
("rgb/rgbGamma", Value::Scalar(1.5)),
("rgb/rgbOutBlack", Value::Scalar(0.05)),
("rgb/rgbOutWhite", Value::Scalar(0.95)),
("red/redGamma", Value::Scalar(0.8)),
("green/greenInWhite", Value::Scalar(0.8)),
("blue/blueOutBlack", Value::Scalar(0.2)),
];
let s = find("ec.color.levelsic").unwrap();
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in &vals {
params.values.insert(k.to_string(), v.clone());
}
let render = |float_depth: bool| {
let env = crate::EffectEnv { float_depth, ..Default::default() };
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
};
let out = render(true);
for (x, want) in ae.iter().enumerate() {
let p = out.get(x as i64, 0);
for c in 0..3 {
let got = p[c].clamp(0.0, 1.0) * 255.0;
assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]);
}
}
// In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black.
let clipped = render(false);
assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0));
}
/// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199)
/// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results.
#[test]
fn curves_follow_a_natural_spline_as_in_after_effects() {
let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255];
let mut img = Image::new(10, 1);
for (x, v) in steps.iter().enumerate() {
let v = *v as f32 / 255.0;
img.set(x as u32, 0, [v, v, v, 1.0]);
}
let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::<Vec<_>>().join(" "));
let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]);
let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]);
let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255];
let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255];
for x in 0..10 {
let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32;
let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32;
assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]);
assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]);
}
}
#[test]
fn levels_ic_identity_and_red_only() {
let img = ramp();
+266 -32
View File
@@ -14,12 +14,27 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
}
/// Rec. 601 luminance on the working-space values (sRGB working space, not linearised: red
/// 76/255, green 150/255, blue 29/255), as After Effects 2026 renders Tint, Tritone, Luma Key and
/// Extract. Tritone, case 718: a red solid with Blend With Original 25 gives (132, 23, 46), from
/// luminance 0.299; Rec. 709's 0.213 gave (113, 16, 49); on a grey ramp both weightings agree
/// (case 815). Luma Key and Extract: Key Out Darker / Extract Black Point at 65, 166 and 24 on
/// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green
/// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it.
pub(crate) fn luma_601(c: [f32; 3]) -> f32 {
LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2]
}
/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU
/// test `shader_constants_match_the_cpu` checks that they are these.
pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114];
fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf {
let black = ctx.params.color("black");
let white = ctx.params.color("white");
let amt = ctx.params.f("amount") as f32 / 100.0;
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let l = luma_601(c).clamp(0.0, 1.0);
let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l];
mix(c, t, amt)
});
@@ -32,7 +47,7 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
let sh = ctx.params.color("shadows");
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let l = luma_601(c).clamp(0.0, 1.0);
let t = if l < 0.5 {
mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0)
} else {
@@ -49,18 +64,22 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of
/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1
/// pass through. The GPU kernel mirrors it.
///
/// Negative Brightness and Contrast are the inverse curves of the positive ones (After Effects
/// 2026 ramps: Brightness −40 is Brightness +40 inverted within 2.6/255, Contrast −50 likewise),
/// so they use the reciprocal exponents: Brightness −40 was 20/255 off AE, now 6.2.
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
if v >= 1.0 {
return v;
}
let mut v = v.max(0.0);
if b > 0.0 {
v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b));
} else if b < 0.0 {
v = v.powf(2f32.powf(-1.32 * b));
if b != 0.0 {
let e = 2f32.powf(1.24 * b.abs());
v = 1.0 - (1.0 - v).powf(if b > 0.0 { e } else { 1.0 / e });
}
if c != 0.0 {
let g = (1.0 + 0.53 * c).max(0.05);
let g = (1.0 + 0.53 * c.abs()).max(0.05);
let g = if c > 0.0 { g } else { 1.0 / g };
v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) };
}
v
@@ -197,16 +216,32 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
pub const LEVELS_CLIP: [&str; 3] = ["Off", "On", "Off for 32 bpc Color"];
/// Whether Levels clips below Input Black / above Input White. "Off for 32 bpc Color" clips
/// (effects can't see the project's bit depth; 8/16 bpc behaviour). The hidden `noClip` switch
/// of projects saved before the two popups existed turns both off.
/// in 8 and 16 bpc projects and not in 32 bpc ones (After Effects 2026, Levels (Individual
/// Controls) in a 32 bpc project: values below Input Black end below Output Black). The hidden
/// `noClip` switch of projects saved before the two popups existed turns both off.
pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) {
if ctx.params.b("noClip") {
return (false, false);
}
let on = |id: &str| ctx.params.get(id).is_none_or(|v| v.as_enum() != 0);
let on = |id: &str| levels_clip_on(ctx, id);
(on("clipToOutputBlack"), on("clipToOutputWhite"))
}
/// One Clip To Output popup: 0 Off, 1 On, 2 Off for 32 bpc Color (missing: clip).
pub fn levels_clip_on(ctx: &EffectCtx, id: &str) -> bool {
match ctx.params.get(id).map(|v| v.as_enum()) {
Some(0) => false,
Some(2) => !ctx.env.float_depth,
_ => true,
}
}
/// Levels' gamma on the normalised input `t`: unclipped values below Input Black extrapolate
/// (the curve mirrored through 0) instead of stopping at Output Black.
pub fn levels_gamma(t: f32, g: f32) -> f32 {
if t >= 0.0 { t.powf(1.0 / g) } else { -(-t).powf(1.0 / g) }
}
/// Levels' Channel popup.
pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"];
@@ -232,8 +267,7 @@ fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool))
if clip_w {
t = t.min(1.0);
}
let t = t.max(0.0).powf(1.0 / g);
ob + (ow - ob) * t
ob + (ow - ob) * levels_gamma(t, g)
}
const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0];
@@ -276,7 +310,7 @@ pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4]
let ids = levels_channel_ids(i + 1);
let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k]));
let pre = LEVELS_CHANNEL_PREFIX[i];
let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0);
let on = |id: String| levels_clip_on(ctx, &id);
let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) };
([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip)
})
@@ -304,6 +338,18 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] {
}
}
/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion
/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005
/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65).
/// `kernels.wgsl` repeats it as a literal; the GPU test `shader_constants_match_the_cpu` checks it.
pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2;
/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless
/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve
/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it
/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a
/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past
/// black and the default settings leave every value unchanged.
fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = exposure_settings(ctx);
let bypass = ctx.params.b("bypassLinearLight");
@@ -313,9 +359,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
if bypass {
return mirror_pow(c[i] * e + off, 1.0 / g);
}
let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0));
let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA);
let o = mirror_pow(lin * e + off, 1.0 / g);
effectcraft_color::linear_to_srgb(o.abs()).copysign(o)
mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA)
})
});
b
@@ -333,6 +379,37 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
/// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted
/// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright
/// and black blocks (#1463), and 32-bpc overbrights divided by zero.
/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a
/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled
/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid
/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2),
/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first.
/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU
/// test `shader_constants_match_the_cpu` checks that they are these.
pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11];
pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] {
let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2];
if !gray.is_finite() || gray <= 0.0 {
return [0.0; 3];
}
if gray >= 1.0 {
return [gray; 3];
}
let d = gray - lum(tint);
let c = tint.map(|v| v + d);
let l = lum(c);
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
let mut o = c;
if n < 0.0 && l - n > 1e-9 {
o = o.map(|v| l + (v - l) * l / (l - n));
}
if x > 1.0 && x - l > 1e-9 {
o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l));
}
o
}
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")]
.map(|v| v as f32 / 100.0);
@@ -347,13 +424,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
let gray = (min + (max - min) * wt).max(0.0);
if tint_on {
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
[r, g, bl]
} else {
[gray, gray, gray]
}
if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] }
});
b
}
@@ -418,15 +489,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
});
return b;
}
// Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After
// Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with
// nothing of the original showing through (it is not a mix with the original).
b.img.data.iter_mut().for_each(|px| {
let a = if invert { 1.0 - px[3] } else { px[3] };
let f = [c[0] * a, c[1] * a, c[2] * a];
for i in 0..3 {
px[i] += (f[i] - px[i]) * opacity;
}
if invert {
px[3] += (a - px[3]) * opacity;
}
let a = if invert { 1.0 - px[3] } else { px[3] } * opacity;
*px = [c[0] * a, c[1] * a, c[2] * a, a];
});
b
}
@@ -969,11 +1037,13 @@ pub fn specs() -> Vec<EffectSpec> {
"Exposure",
{
let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))];
// After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from
// older projects or Photoshop imports (0.01..9.99) are kept and still render.
let group = |ids: [&'static str; 3]| {
[
p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)),
p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)),
p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)),
p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)),
]
};
v.extend(group(["master/exposure", "master/offset", "master/gamma"]));
@@ -1173,6 +1243,17 @@ mod tests {
}
}
/// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working
/// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values.
#[test]
fn tint_defaults_match_after_effects_greys() {
for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]);
let want = want / 255.0;
assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}");
}
}
#[test]
fn colorama_reads_get_phase_from() {
// Same intensity, different red: Intensity gives one colour, Red gives two.
@@ -1227,6 +1308,75 @@ mod tests {
assert!(go(0.0, 100.0, 64) >= 30);
}
/// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows
/// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46);
/// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64),
/// (153, 51, 51), (204, 153, 128).
#[test]
fn tritone_matches_after_effects() {
let colours = [
("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])),
("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])),
("shadows", Value::Color([0.0, 0.0, 0.3, 1.0])),
];
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0);
let mut red = colours.to_vec();
red.push(("blend", num(25.0)));
let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]);
assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0));
for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] {
let v = level / 255.0;
let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]);
assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0));
}
}
/// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow
/// and white solids at the default sliders (case 825).
#[test]
fn black_white_tint_matches_after_effects() {
let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))];
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5);
let mut reds = tint.to_vec();
reds.push(("reds", num(10.0)));
let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]);
assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0));
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]);
assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0));
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]);
assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0));
// Hostile grays stay finite.
for g in [f32::NAN, -1.0, 0.0, 4.0] {
assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite()));
}
}
/// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast
/// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit)
#[test]
fn negative_brightness_and_contrast_invert_the_positive_curves() {
let go = |b: f64, c: f64, x: u8| {
let v = x as f32 / 255.0;
(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32
};
let samples: &[(f64, f64, u8, u8)] = &[
(-40.0, 0.0, 64, 50),
(-40.0, 0.0, 128, 99),
(-40.0, 0.0, 191, 155),
(-40.0, 0.0, 234, 207),
(0.0, -50.0, 21, 28),
(0.0, -50.0, 64, 76),
(0.0, -50.0, 191, 179),
(0.0, -50.0, 234, 227),
(40.0, 0.0, 128, 162),
(0.0, 50.0, 64, 52),
];
for &(b, c, x, ae) in samples {
let got = go(b, c, x);
assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}");
}
}
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
/// colour stays finite (it used to divide by zero).
@@ -1321,6 +1471,66 @@ mod tests {
assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}");
}
/// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma
/// of 0.05, valid before, as in Photoshop) survives loading and still renders.
#[test]
fn exposure_has_after_effects_ranges_and_keeps_old_values() {
use effectcraft_project::build::Ids;
let spec = crate::find("ec.color.exposure").unwrap();
let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)];
let sliders: Vec<_> = spec
.params
.iter()
.filter_map(|p| match p.ui {
ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))),
_ => None,
})
.collect();
assert_eq!(sliders.len(), 12);
for (i, (id, r)) in sliders.iter().enumerate() {
assert_eq!(*r, want[i % 3], "{id}");
}
let mut next = 1;
let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
let gamma = g.prop_mut("master/gamma").unwrap();
gamma.value = num(0.05);
gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2);
crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
let gamma = g.prop_mut("master/gamma").unwrap();
assert_eq!(gamma.value, num(0.05));
assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1));
let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]);
assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}");
}
/// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space,
/// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power.
/// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets.
#[test]
fn exposure_matches_after_effects_gamma_2_2() {
let red = [1.0, 0.0, 0.0, 1.0];
let code = |o: [f32; 4], i: usize| o[i] * 255.0;
// Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture).
for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] {
let o = px("ec.color.exposure", &[("master/offset", num(offset))], red);
for i in [1, 2] {
assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i));
}
assert!(o[0] >= 1.0, "{o:?}");
}
// Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54.
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red);
assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red);
assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
// Default settings leave 32-bpc values alone, negative and over-range ones included.
let hdr = [-0.25, 0.5, 2.0, 1.0];
assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5));
// A negative Offset pushes black below zero, mirrored, rather than clipping or NaN.
let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]);
assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}");
}
#[test]
fn change_to_color_change_modes_tolerances_and_matte() {
let red = [0.8, 0.1, 0.1, 1.0];
@@ -1462,6 +1672,30 @@ mod tests {
assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
}
/// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp
/// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the
/// fill colour at half the layer's alpha, nothing of the ramp left.
#[test]
fn fill_opacity_is_the_layers_opacity_as_in_after_effects() {
let mut img = Image::new(16, 4);
for x in 0..16 {
let v = x as f32 / 15.0;
for y in 0..4 {
img.set(x, y, [v, 0.0, 0.0, 1.0]);
}
}
let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))];
let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img;
for x in [0, 7, 15] {
let p = out.get(x, 2);
let ae = [0.0, 0.251, 0.502, 0.502];
assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}");
}
// A half-transparent pixel ends at a quarter alpha, still the fill colour.
let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img;
assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]);
}
#[test]
fn colorama_palettes_modify_selection_masking_and_add_phase() {
let grey = [0.5, 0.5, 0.5, 1.0];
+122
View File
@@ -0,0 +1,122 @@
//! Editing a Cryptomatte effect's Selection text from a pick in the viewer (#473): replace it
//! with the picked ID, add it, or remove it, in the grammar `selection_hashes` reads.
use crate::channel3d::{selection_hashes, selection_names};
/// How a pick changes the selection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PickMode {
Replace,
Add,
Remove,
}
/// A name as a selection token: quoted (with `\` escapes) when it has a comma, a quote or a
/// backslash, which the selection parser would otherwise split on or read as syntax.
fn quote(name: &str) -> String {
if !name.contains([',', '"', '\'', '\\']) {
return name.to_string();
}
let mut out = String::from("\"");
for c in name.chars() {
if c == '"' || c == '\\' {
out.push('\\');
}
out.push(c);
}
out.push('"');
out
}
/// The selection token for the ID `hash` named `name` in `manifest`: the (quoted) name when it
/// reads back as exactly that ID, else `<hex>`. That covers unnamed IDs, names with `*` (which
/// would select by wildcard), names that look like `<hex>`, names with surrounding spaces (the
/// parser trims them) and names shared by several IDs.
pub fn token_for(name: Option<&str>, hash: u32, manifest: Option<&[(String, u32)]>) -> String {
match name {
Some(n) if !n.is_empty() && !n.contains('*') && selection_hashes(&quote(n), manifest) == [hash] => quote(n),
_ => format!("<{hash:08x}>"),
}
}
/// `sel` edited by a pick of the ID `hash` whose token is `token` (see [`token_for`]). The text
/// stays as typed when the edit wouldn't change what it selects: Replace when it selects just
/// that ID, Add when it already selects it, Remove when no name or `<hex>` token selects it (a
/// wildcard that matches it stays). Other tokens (wildcards included) keep their text,
/// re-quoted where needed, separated by `, `.
pub fn edit_selection(sel: &str, token: &str, hash: u32, mode: PickMode, manifest: Option<&[(String, u32)]>) -> String {
let tokens: Vec<String> = selection_names(sel).into_iter().filter(|t| !t.is_empty()).collect();
let selects = |t: &String| !t.contains('*') && selection_hashes(&quote(t), manifest).contains(&hash);
let kept: Vec<String> = match mode {
PickMode::Replace if selection_hashes(sel, manifest) == [hash] => return sel.to_string(),
PickMode::Replace => return token.to_string(),
PickMode::Add if selection_hashes(sel, manifest).contains(&hash) => return sel.to_string(),
PickMode::Add => tokens.iter().map(|t| quote(t)).chain([token.to_string()]).collect(),
PickMode::Remove if !tokens.iter().any(selects) => return sel.to_string(),
PickMode::Remove => tokens.iter().filter(|t| !selects(t)).map(|t| quote(t)).collect(),
};
kept.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_raster::channels3d::crypto_hash;
fn manifest() -> Vec<(String, u32)> {
["bunny", "Green, Glossy", r#"say "hi""#, r"back\slash", " pad ", "star*", "<0000abcd>", "twin"]
.iter()
.map(|n| (n.to_string(), crypto_hash(n)))
.chain([("twin".to_string(), 7)])
.collect()
}
/// Every name's token reads back as exactly its ID, through the effect's own parser.
#[test]
fn tokens_round_trip_through_selection_hashes() {
let m = manifest();
for (n, h) in &m {
let t = token_for(Some(n), *h, Some(&m));
assert_eq!(selection_hashes(&t, Some(&m)), [*h], "{n:?} → {t:?}");
}
assert_eq!(token_for(Some("bunny"), crypto_hash("bunny"), Some(&m)), "bunny");
assert_eq!(token_for(Some("Green, Glossy"), crypto_hash("Green, Glossy"), Some(&m)), r#""Green, Glossy""#);
assert_eq!(token_for(Some(r#"say "hi""#), crypto_hash(r#"say "hi""#), Some(&m)), r#""say \"hi\"""#);
// Wildcard-looking, hash-looking, empty, unnamed and shared names: the hash.
assert_eq!(token_for(Some("star*"), crypto_hash("star*"), Some(&m)), format!("<{:08x}>", crypto_hash("star*")));
assert_eq!(token_for(Some("<0000abcd>"), crypto_hash("<0000abcd>"), Some(&m)), format!("<{:08x}>", crypto_hash("<0000abcd>")));
assert_eq!(token_for(Some(""), 5, Some(&m)), "<00000005>");
assert_eq!(token_for(Some(" pad "), crypto_hash(" pad "), Some(&m)), format!("<{:08x}>", crypto_hash(" pad ")));
assert_eq!(token_for(None, 0xdead_beef, None), "<deadbeef>");
assert_eq!(token_for(Some("twin"), 7, Some(&m)), "<00000007>", "the name selects the other twin");
}
#[test]
fn add_and_remove_keep_other_tokens_and_skip_duplicates() {
let m = manifest();
let (b, g) = (crypto_hash("bunny"), crypto_hash("Green, Glossy"));
let tok = |n: &str| token_for(Some(n), crypto_hash(n), Some(&m));
let edit = |sel: &str, n: &str, mode| edit_selection(sel, &tok(n), crypto_hash(n), mode, Some(&m));
assert_eq!(edit("anything, else", "bunny", PickMode::Replace), "bunny");
assert_eq!(edit(" bunny ", "bunny", PickMode::Replace), " bunny ", "same meaning: unchanged");
assert_eq!(edit("", "bunny", PickMode::Add), "bunny");
assert_eq!(edit("bunny", "Green, Glossy", PickMode::Add), r#"bunny, "Green, Glossy""#);
// Already selected: by name, by hash, by a wildcard. The text stays as typed.
assert_eq!(edit("bunny,floor", "bunny", PickMode::Add), "bunny,floor");
assert_eq!(edit(&format!("<{b:08x}>"), "bunny", PickMode::Add), format!("<{b:08x}>"));
assert_eq!(edit("bun*", "bunny", PickMode::Add), "bun*");
// Other tokens keep their meaning: quoted where needed, wildcards stay wildcards.
assert_eq!(edit(r#"'a, b', fl*, "x, *""#, "bunny", PickMode::Add), r#""a, b", fl*, "x, *", bunny"#);
assert_eq!(edit(r#""Green, Glossy", bunny"#, "bunny", PickMode::Remove), r#""Green, Glossy""#);
assert_eq!(edit(&format!("bunny, <{b:08x}>, x, bunny"), "bunny", PickMode::Remove), "x");
assert_eq!(edit(&format!("<{g:08x}>"), "Green, Glossy", PickMode::Remove), "");
// Not selected, or only by a wildcard: unchanged.
assert_eq!(edit("a ,b", "bunny", PickMode::Remove), "a ,b");
assert_eq!(edit("bun*, x", "bunny", PickMode::Remove), "bun*, x");
for sel in [r#""a, b", fl*, bunny"#, r#"x, "Green, Glossy", "say \"hi\"", "back\\slash""#] {
let before = selection_hashes(sel, Some(&m));
let after = selection_hashes(&edit(sel, " pad ", PickMode::Add), Some(&m));
assert_eq!(after, [before, vec![crypto_hash(" pad ")]].concat(), "{sel}");
}
}
}
+64 -2
View File
@@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px {
}
}
/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point
/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100
/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error
/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0.
pub fn four_color_softening(blend: f64, scale: f64) -> f64 {
let b = blend.max(0.0) * scale;
(b * b).max(1e-6)
}
fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pts: Vec<((f64, f64), [f32; 4])> =
(1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect();
let blend = ctx.params.f("blend").max(1.0);
let k = four_color_softening(ctx.params.f("blend"), b.scale);
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
let op = ctx.params.f("opacity") as f32 / 100.0;
let mode = ctx.params.e("blendingMode");
@@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let mut wsum = 0.0;
for ((qx, qy), c) in &pts {
let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2);
let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5);
let w = 1.0 / (d2 + k);
for i in 0..3 {
acc[i] += c[i] as f64 * w;
}
@@ -423,6 +432,59 @@ mod generator_tests {
}
}
/// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 %
/// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10
/// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5).
#[test]
fn four_color_blend_matches_after_effects() {
let pts = [
("positionsColors/point1", [19.2, 10.8]),
("positionsColors/point2", [172.8, 10.8]),
("positionsColors/point3", [19.2, 97.2]),
("positionsColors/point4", [172.8, 97.2]),
];
let cols = [
("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]),
("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]),
("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]),
("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]),
];
let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [
(
10.0,
[
(30, 20, [249.0, 240.0, 15.0]),
(150, 30, [20.0, 208.0, 47.0]),
(60, 90, [213.0, 56.0, 199.0]),
(100, 10, [115.0, 177.0, 78.0]),
(10, 50, [238.0, 145.0, 110.0]),
],
),
(
30.0,
[
(30, 20, [236.0, 214.0, 41.0]),
(150, 30, [30.0, 189.0, 66.0]),
(60, 90, [203.0, 67.0, 188.0]),
(100, 10, [117.0, 173.0, 82.0]),
(10, 50, [233.0, 140.0, 115.0]),
],
),
];
for (blend, samples) in ae {
let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect();
vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v))));
vals.push(("blend", num(blend)));
let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default());
for (x, y, want) in samples {
let p = o.img.get(x, y);
for c in 0..3 {
assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]);
}
}
}
}
#[test]
fn four_color_none_stays_inside_the_layer_alpha() {
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
+77 -37
View File
@@ -2,12 +2,13 @@
//! matte clean-up. Written from public behaviour descriptions and the standard colour-difference /
//! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting").
use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear};
use effectcraft_color::{rgb_to_hsl, srgb_to_linear};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, gaussian_blur};
use rayon::prelude::*;
use crate::color_fx::luma_601;
use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
@@ -135,46 +136,23 @@ fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---- Luma Key ----
/// Luma Key as After Effects 2026 keys a grey ramp (cases 812-814, 719): Key Out Brighter and
/// Darker cut hard at Threshold and ignore Tolerance; Key Out Similar removes, and Dissimilar
/// keeps, a hard band of Threshold ± Tolerance (levels of 255). Edge Thin and Edge Feather
/// then soften the cut.
fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
let kind = ctx.params.e("keyType");
let thr = ctx.params.f("threshold") as f32 / 255.0;
let tol = ctx.params.f("tolerance") as f32 / 255.0;
let m = matte_of(&b.img, |c, _| {
let l = luminance(c[0], c[1], c[2]);
match kind {
0 => {
if tol > 0.0 {
((thr + tol - l) / tol).clamp(0.0, 1.0)
} else if l > thr {
0.0
} else {
1.0
}
}
2 => {
if (l - thr).abs() <= tol {
0.0
} else {
1.0
}
}
3 => {
if (l - thr).abs() <= tol {
1.0
} else {
0.0
}
}
_ => {
if tol > 0.0 {
((l - (thr - tol)) / tol).clamp(0.0, 1.0)
} else if l < thr {
0.0
} else {
1.0
}
}
}
let l = luma_601([c[0], c[1], c[2]]);
let keep = match kind {
0 => l <= thr,
2 => (l - thr).abs() > tol,
3 => (l - thr).abs() <= tol,
_ => l >= thr,
};
if keep { 1.0 } else { 0.0 }
});
let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale);
apply_matte(&mut b.img, &m, 0);
@@ -334,7 +312,7 @@ fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf {
2 => c[1],
3 => c[2],
4 => a,
_ => luminance(c[0], c[1], c[2]),
_ => luma_601([c[0], c[1], c[2]]),
};
let mut k = 1.0f32;
if v < bp {
@@ -802,6 +780,68 @@ mod tests {
assert_eq!(out.get(1, 0)[3], 1.0);
}
/// After Effects 2026 (cases 812-814): Luma Key on a vertical black-to-white ramp (row y
/// has level (y + 0.5) / 1080 × 255), Threshold 140, Tolerance 20. EffectCraft ramped the
/// matte over the tolerance (case 719: a 50 % grey kept 80 % of its alpha where AE keys it
/// out).
#[test]
fn luma_key_matches_after_effects() {
let ramp = || {
let mut img = Image::new(1, 1080);
for y in 0..1080 {
let v = (y as f32 + 0.5) / 1080.0;
img.set(0, y, [v, v, v, 1.0]);
}
img
};
let key = |kind: u32, thin: f64, feather: f64| {
let set =
[("keyType", Value::Enum(kind)), ("threshold", num(140.0)), ("tolerance", num(20.0)), ("edgeThin", num(thin)), ("edgeFeather", num(feather))];
let o = run("ec.key.luma", &set, ramp());
(0..1080).map(|y| o.get(0, y)[3]).collect::<Vec<f32>>()
};
// Key Out Darker (AE 2 = EffectCraft 1): keyed up to row 592, kept from 593.
let d = key(1, 0.0, 0.0);
assert!(d[..593].iter().all(|a| *a == 0.0) && d[593..].iter().all(|a| *a == 1.0), "{:?}", &d[588..598]);
// Key Out Similar: AE keys rows 509..=677 (levels 120.3..160.0) and keeps the rest; the
// rows at the band's ends sit within 0.1 level of 120 / 160, so they may go either way.
let s = key(2, 0.0, 0.0);
assert!(
(0..1080).filter(|y| !(507..=510).contains(y) && !(676..=679).contains(y)).all(|y| (s[y] == 0.0) == (509..=677).contains(&y)),
"{:?} {:?}",
&s[505..512],
&s[674..681]
);
// Key Out Brighter and Dissimilar are the complements.
let b = key(0, 0.0, 0.0);
assert!(b[595] == 0.0 && b[590] == 1.0);
let ds = key(3, 0.0, 0.0);
assert!(ds[600] == 1.0 && ds[400] == 0.0 && ds[700] == 0.0);
// A 50 % grey at Darker 140 / 20 is keyed out entirely (case 719).
let o =
run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(140.0)), ("tolerance", num(20.0))], Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]));
assert_eq!(o.get(0, 0)[3], 0.0);
// Edge Thin 1, Edge Feather 2.5 (case 814): AE's alpha rises over rows 591..596.
let f = key(1, 1.0, 2.5);
for (y, ae) in [(590, 0.0), (591, 0.026), (592, 0.139), (593, 0.357), (594, 0.635), (595, 0.868), (596, 0.97), (597, 1.0)] {
assert!((f[y] - ae).abs() < 0.2, "row {y}: {} vs AE {ae}", f[y]);
}
}
/// After Effects 2026: Key Out Darker and Extract's Black Point at 65 / 166 / 24 on pure
/// red / green / blue keep red and blue and key out green: Rec. 601 luminance (Rec. 709
/// would key out red and blue and keep green).
#[test]
fn luma_key_and_extract_use_rec601_as_after_effects() {
for (c, thr, kept) in [([1.0, 0.0, 0.0], 65.0, true), ([0.0, 1.0, 0.0], 166.0, false), ([0.0, 0.0, 1.0], 24.0, true)] {
let img = Image::filled(2, 1, [c[0], c[1], c[2], 1.0]);
let key = run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(thr))], img.clone());
let ext = run("ec.key.extract", &[("blackPoint", num(thr))], img);
let want = if kept { 1.0 } else { 0.0 };
assert_eq!((key.get(0, 0)[3], ext.get(0, 0)[3]), (want, want), "{c:?} at {thr}");
}
}
#[test]
fn extract_keeps_range() {
let mut img = Image::new(3, 1);
+45 -5
View File
@@ -21,6 +21,7 @@ mod color2;
mod color3;
mod color_fx;
pub mod controls;
pub mod crypto_select;
mod distort;
mod distort2;
mod distort3;
@@ -70,11 +71,11 @@ use std::collections::HashMap;
use std::sync::OnceLock;
pub use card3d::{CompLight, CompScene};
pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette};
pub use color_fx::{
HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity,
levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip,
BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, LUMA_601, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings,
fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip,
};
pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette};
pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts};
pub use color3::OffsetCurve;
pub use distort::transform_shutter;
@@ -91,7 +92,7 @@ pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state};
pub use stylize2::strobe_on;
// CPU helpers the GPU kernels share (effectcraft-gpu).
pub use blur2::{camera_lens_plain, camera_lens_spans};
pub use generate::gen_mode;
pub use generate::{four_color_softening, gen_mode};
pub use generate2::pattern_kind as cell_pattern_kind;
pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan};
pub use generate3::{
@@ -110,7 +111,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time,
// effectcraft-gpu fx_tone.
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, id_color, selection_hashes};
pub use channel3d::{CRYPTO_LAYERS, aux_point, crypto_aux_image, id_color, selection_hashes};
pub use ocio::{ColorOp, Straight, Tf, color_program};
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan};
pub use sim2::{
@@ -297,6 +298,43 @@ impl Buf {
}
}
/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`:
/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its
/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp
/// origin in the buffer below (After Effects; a Transform after it brings the tiles into view,
/// #1727). An adjustment at 100 % keeps the buffer it was given; one whose frame would pass
/// 16 384 pixels a side does too.
pub fn motion_tile_frame(ctx: &EffectCtx, offset: [f64; 2], size: (u32, u32), scale: f64) -> (u32, u32, [f64; 2]) {
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
let (pw, ph) = (ctx.params.f("outputWidth"), ctx.params.f("outputHeight"));
let ow = (lw * pw / 100.0).max(1.0 / scale);
let oh = (lh * ph / 100.0).max(1.0 / scale);
let (nw, nh) = ((ow * scale).round().max(1.0), (oh * scale).round().max(1.0));
let grow = [(ow - lw) * 0.5 * scale, (oh - lh) * 0.5 * scale];
if !ctx.adjustment {
return (nw as u32, nh as u32, grow);
}
// The grown rectangle's top-left in the buffer below, on whole pixels so the result lines
// up with the comp again when the stack ends.
let (x0, y0) = ((offset[0] - grow[0]).round(), (offset[1] - grow[1]).round());
let fits = [x0, y0, nw, nh].iter().all(|v| v.is_finite()) && nw <= 16384.0 && nh <= 16384.0;
if (pw == 100.0 && ph == 100.0) || !fits {
return (size.0, size.1, offset);
}
(nw as u32, nh as u32, [offset[0] - x0, offset[1] - y0])
}
/// Where an adjustment stack's result (comp origin at `offset`, `size` pixels) lies on the canvas
/// it came from (comp origin at `canvas_offset`): the result pixel at canvas pixel (0, 0).
/// `None` when it already is the canvas, frame for frame.
pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u32), canvas: (u32, u32)) -> Option<(i64, i64)> {
let (dx, dy) = ((offset[0] - canvas_offset[0]).round(), (offset[1] - canvas_offset[1]).round());
if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0 && size == canvas) {
return None;
}
Some((dx as i64, dy as i64))
}
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
@@ -406,6 +444,8 @@ pub struct EffectEnv<'a> {
pub working_space: Option<effectcraft_color::ColorSpace>,
/// Working-space pixels are linear light (Linearize Working Space).
pub working_linear: bool,
/// The project works in 32 bpc float (Levels' "Off for 32 bpc Color" clips only below it).
pub float_depth: bool,
/// The composition's shutter (angle and phase in degrees, samples per frame) when both the
/// comp's and the layer's motion blur switches are on (Transform's Use Composition's
/// Shutter Angle).
+17 -3
View File
@@ -17,7 +17,7 @@ use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use rayon::prelude::*;
use crate::generate::{fade, lattice};
use crate::generate::lattice;
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider};
/// Fractal Type options (shared by both effects).
@@ -139,7 +139,7 @@ fn bspline(t: f32) -> [f32; 3] {
}
/// 3D value noise in about 0..1 with the given [`NOISE_TYPES`] interpolation in x and y (the
/// evolution axis z always fades smoothly).
/// evolution axis z always interpolates linearly, so evolution moves at a constant rate).
///
/// Soft Linear is linear interpolation softened by a one-cell box filter, which is the quadratic
/// B-spline: smooth like Spline from 9 lattice values instead of 16. A fade between two values
@@ -149,7 +149,7 @@ pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 {
// Soft Linear's taps are centred on the nearest lattice point, the others start at the cell's.
let h = if noise_type == NOISE_SOFT_LINEAR { 0.5 } else { 0.0 };
let (x0, y0, z0) = ((x + h).floor(), (y + h).floor(), z.floor());
let (tx, ty, fz) = (x + h - x0, y + h - y0, fade(z - z0));
let (tx, ty, fz) = (x + h - x0, y + h - y0, z - z0);
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
let slice = |dz: i32| -> f32 {
@@ -476,6 +476,20 @@ mod tests {
run_fx(id, vals, Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]), 0.0, EffectEnv::default()).img
}
/// Evolution moves the noise at a constant rate: no ease in or out within a revolution.
#[test]
fn evolution_is_linear() {
for noise_type in [0, 1, 2, 3] {
let a = typed_noise(0.3, 0.7, 0.0, 5, noise_type);
let b = typed_noise(0.3, 0.7, 1.0, 5, noise_type);
for z in [0.25, 0.5, 0.75] {
let want = a + (b - a) * z;
let got = typed_noise(0.3, 0.7, z, 5, noise_type);
assert!((got - want).abs() < 1e-4, "type {noise_type} z {z}: {got} vs {want}");
}
}
}
/// A Fractal Noise saved before the twirl-downs existed: flat ids move into their groups and
/// the new controls take values that keep the old look.
#[test]
+1 -7
View File
@@ -433,13 +433,7 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf {
let mirror = ctx.params.b("mirrorEdges");
let phase = ctx.params.f("phase") / 360.0;
let hshift = ctx.params.b("horizontalPhaseShift");
let (nw, nh, noff) = if ctx.adjustment {
(b.img.width, b.img.height, b.offset)
} else {
let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s);
let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s);
((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s])
};
let (nw, nh, noff) = crate::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s);
let mut out = Image::new(nw, nh);
let src = &b.img;
out.rows_mut().for_each(|(y, row)| {
+110
View File
@@ -186,6 +186,101 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"prop": uid, "color": color}))
}
/// What a Cryptomatte effect has at a layer-space point: the ID with the most coverage in its
/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection.
struct CryptoHit {
at: (effectcraft_project::ItemId, LayerId, Tick),
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
layer: String,
hash: u32,
selection: String,
prop: Uid,
/// The Selection has an expression: setting its value wouldn't change what it reads.
driven: bool,
}
impl CryptoHit {
fn manifest(&self) -> Option<&[(String, u32)]> {
self.aux.manifests.iter().find(|(l, _)| *l == self.layer).map(|(_, m)| m.as_slice())
}
fn reply(&self, sel: &str) -> Value {
let selected = effectcraft_effects::selection_hashes(sel, self.manifest()).contains(&self.hash);
let name = self.aux.manifest_name(&self.layer, self.hash);
json!({"name": name, "hash": format!("{:08x}", self.hash), "layer": self.layer, "selected": selected})
}
}
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
/// (the viewer's resolution).
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
let scale = f_p(p, "scale").unwrap_or(1.0);
if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) {
return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?;
let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() });
let aux = r.layer_aux(&ctx, layer);
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else {
return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"));
};
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up,
// proxies have their own size, earlier effects can pad it), rendered and cached.
let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
let (bs, o) = (b.scale, b.offset);
// The centre of its pixel under the point, at the aux point the effect reads there.
let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]);
let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) };
let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled));
Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
}
/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per
/// frame renders the effect's input to learn its pixel grid (cached afterwards).
fn crypto_at(s: &mut Session, p: &Value) -> Result<Value> {
Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection)))
}
/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it
/// or remove it, in one undo step at the time it was read (none when its meaning stays).
fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_effects::crypto_select::{PickMode, edit_selection, token_for};
let cmd = "effect.pickCryptomatte";
let mode = match str_p(p, "mode").unwrap_or("replace") {
"replace" => PickMode::Replace,
"add" => PickMode::Add,
"remove" => PickMode::Remove,
m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))),
};
let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?;
if h.driven {
return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick"));
}
let m = h.manifest();
let sel = edit_selection(&h.selection, &token_for(h.aux.manifest_name(&h.layer, h.hash), h.hash, m), h.hash, mode, m);
let changed = sel != h.selection;
if changed {
let (cid, lid, t) = h.at;
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?;
}
let mut out = h.reply(&sel);
out["selection"] = json!(sel);
out["changed"] = json!(changed);
Ok(out)
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cid = super::comp_id(s, p)?;
let fx = effects_p(s, p, "effect.remove")?;
@@ -632,6 +727,21 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
pick_color
),
cmd!(
"effect.pickCryptomatte",
"Pick Cryptomatte",
[],
None,
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point",
has_layers,
pick_crypto
),
crate::query!(
"effect.cryptomatteAt",
"Cryptomatte At",
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point",
crypto_at
),
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all),
cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove),
+10 -1
View File
@@ -665,6 +665,15 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t)
.ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?;
}
if let Some(f) = p.get("frameStart") {
// Frame Count: After Effects offers Start at 0 and Start at 1 only. Scripts and dialog
// number fields may send `1.0`.
proj.settings.frame_start = match f.as_f64() {
Some(0.0) => 0,
Some(1.0) => 1,
_ => return Err(bad(cmd, format!("frameStart: 0|1, not `{f}`"))),
};
}
Ok(())
})?;
Ok(serde_json::to_value(&s.project.settings).unwrap_or_default())
@@ -745,7 +754,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Project Settings...",
["File"],
Some("Cmd+Alt+Shift+K"),
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}",
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16, frameStart?: 0|1 (Frame Count: Start at 0 / Start at 1)}",
always,
project_settings
),
+8 -3
View File
@@ -22,8 +22,11 @@ pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Lay
});
}
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?;
let pr = layer.props.prop(path).ok_or_else(|| {
let group = layer.props.group(path).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid)));
// `effects/<match name>[#n]/<property>` is how `essential.list`, presets and Copy write an
// effect property; turn it into the uid path `PropGroup::prop` parses (#2064).
let resolved = super::resolve_effect_path(layer, path);
let pr = layer.props.prop(&resolved).ok_or_else(|| {
let group = layer.props.group(&resolved).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid)));
if group.is_some() {
bad(cmd, format!("`{path}` is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property"))
} else {
@@ -179,7 +182,9 @@ pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: &
pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value {
let ct = layer.comp_time(t);
let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()});
if !layer.is_active_at(ct) {
// A key exactly on the out point ends a fade normally: it is outside the half-open visible
// range, but it is not a caller mistake, so it does not warn (#2084).
if ct < layer.in_point || ct > layer.out_point {
out["warning"] = json!(format!(
"Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).",
ct.seconds(),
+14
View File
@@ -28,6 +28,20 @@ pub fn display_time(s: &Session, comp: &Comp, t: Tick) -> String {
}
}
/// A length (a layer's Duration) in the project's Time Display Style: a count of frames, so
/// neither Start Numbering Frames At nor the comp's Start Timecode moves it.
pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String {
let fr = comp.frame_rate;
let n = fr.frame_at(len);
match s.project.settings.time_display {
TimeDisplayStyle::Frames => format!("{n:05}"),
TimeDisplayStyle::Feet35 | TimeDisplayStyle::Feet16 => {
effectcraft_time::format_feet_frames(n, s.project.settings.time_display.frames_per_foot().unwrap_or(16))
}
TimeDisplayStyle::Timecode => effectcraft_time::format_timecode_ae(n, fr, false),
}
}
/// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame
/// `current`) into a comp frame.
pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result<i64, String> {
+9 -1
View File
@@ -1036,12 +1036,18 @@ impl Session {
/// Render a comp frame with the session's footage + expression hosts.
pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image {
self.renderer(opts).comp_frame(comp, t)
}
/// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and
/// accelerator, and the render prefs (nested switches, draft shadows) over `opts`.
pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> {
let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts };
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
r.expr = self.expr.as_deref();
r.cache = Some(&self.layer_cache);
r.accel = self.accel.as_deref();
r.comp_frame(comp, t)
r
}
/// Resolve a comp reference (item id number or name; `None`/null = the active comp).
@@ -1242,6 +1248,8 @@ mod tests_codec_options;
#[cfg(test)]
mod tests_color_view;
#[cfg(test)]
mod tests_crypto_pick;
#[cfg(test)]
mod tests_delete_keys;
#[cfg(test)]
mod tests_disk_cache;
+1
View File
@@ -733,6 +733,7 @@ Layer
Shape Layer | layer.newShape
Adjustment Layer | layer.newAdjustment
Content-Aware Fill Layer... | layer.newContentAwareFill
3D Primitive... | layer.newPrimitive
Cube | layer.new3dPrimitive {"kind":"cube"}
Sphere | layer.new3dPrimitive {"kind":"sphere"}
Plane | layer.new3dPrimitive {"kind":"plane"}
+5
View File
@@ -116,6 +116,11 @@ impl Session {
self.layer_cache.set_disk(None);
return;
};
// Tests: the Roto/Object Matte segment store below is one per process, so a test that
// opens a disk cache (often the platform folder, through a settings store) would take
// over the store of a roto test running beside it. Serialise them with the roto tests.
#[cfg(test)]
crate::tests_roto::hold_roto_cache_test_lock();
let dc = match &self.disk_cache {
Some(c) if c.folder() == folder.as_path() => {
c.set_max_bytes(max);
+336
View File
@@ -0,0 +1,336 @@
//! `effect.pickCryptomatte` / `effect.cryptomatteAt` (#473): the Cryptomatte eyedropper reads the
//! aux channels the effect renders from (footage at its source time, a 3D precomp's generated
//! pass) and edits the Selection text in one undo step.
use std::sync::Arc;
use effectcraft_project::{Footage, FootageKind, ItemId};
use effectcraft_raster::channels3d::{crypto_float, crypto_hash};
use effectcraft_raster::{AuxChannels, Image};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use crate::Session;
use crate::render::FootageSource;
const COMMA: &str = "Green, \"Glossy\"";
/// 8 × 4 footage with a 16 × 8 Cryptomatte (two aux pixels per layer pixel). Layer pixels in x:
/// 0-1 `left` (0.6) over `right` (0.4); 2-3 `left` (0.3) under the comma name (0.7), ranks out of
/// order; 4-5 `star*`; 6 an ID missing from the manifest; 7 a 0.5 / 0.5 tie of `left` and
/// `right`. Rows 3+ are empty. From 1 s of source time `late` replaces columns 0-1.
struct Crypto;
fn aux_at(t: Tick) -> AuxChannels {
let (w, h) = (16usize, 8usize);
let mut planes = vec![vec![0.0f32; w * h]; 4];
for (i, x) in (0..w * h).map(|i| (i, i % w)).filter(|(i, _)| i / w < 6) {
let late = if t >= Tick::from_seconds_f64(1.0) { "late" } else { "left" };
let (a, ca, b, cb) = match x {
0..4 => (late, 0.6, "right", 0.4),
4..8 => ("left", 0.3, COMMA, 0.7),
8..12 => ("star*", 1.0, "", 0.0),
12..14 => ("ghost", 1.0, "", 0.0),
_ => ("left", 0.5, "right", 0.5),
};
for (k, v) in [crypto_float(crypto_hash(a)), ca, crypto_float(crypto_hash(b)), cb].into_iter().enumerate() {
planes[k][i] = v;
}
}
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
for (k, c) in ["R", "G", "B", "A"].iter().enumerate() {
aux.channels.push((format!("CryptoObject00.{c}"), planes[k].clone()));
}
aux.manifests = vec![("CryptoObject".into(), ["left", "right", "late", COMMA, "star*"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())];
aux
}
impl FootageSource for Crypto {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
Some(Arc::new(aux_at(t)))
}
}
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
let duration = Tick::from_seconds_f64(2.0);
// A half-size proxy of the EXR.
let (width, height) = if path.contains("proxy") { (4, 2) } else { (8, 4) };
Ok(Footage {
path: path.into(),
kind: FootageKind::Video,
width,
height,
has_video: true,
frame_rate: FrameRate::from_f64(24.0),
duration,
..Default::default()
})
}
}
fn session() -> Session {
let mut s = Session { footage: Arc::new(Crypto), importer: Some(Arc::new(Probe)), ..Default::default() };
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
s
}
fn pick(s: &mut Session, x: f64, mode: &str) -> Value {
s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1.5, "mode": mode})).unwrap()
}
fn selection(s: &Session) -> String {
let l = &s.active_comp().unwrap().layers[0];
let g = l.effects().unwrap().groups().next().unwrap();
match &g.prop("selection").unwrap().value {
effectcraft_keyframe::Value::Str(t) => t.clone(),
v => panic!("{v:?}"),
}
}
#[test]
fn picks_replace_add_and_remove_in_one_undo_step_each() {
let mut s = session();
let steps = s.history.undo.len();
let r = pick(&mut s, 0.5, "replace");
assert_eq!((r["name"].as_str(), r["selection"].as_str(), r["changed"].as_bool()), (Some("left"), Some("left"), Some(true)), "{r}");
assert_eq!(r["hash"], format!("{:08x}", crypto_hash("left")));
assert_eq!(s.history.undo.len(), steps + 1);
// The highest coverage wins even when it is not rank 0; the name is quoted.
assert_eq!(pick(&mut s, 2.5, "add")["selection"], r#"left, "Green, \"Glossy\"""#);
// Adding it again changes nothing and leaves no undo step.
let again = pick(&mut s, 3.5, "add");
assert_eq!((again["changed"].as_bool(), s.history.undo.len()), (Some(false), steps + 2));
// A `*` name and an unnamed ID go in as hashes; a tie goes to rank 0.
pick(&mut s, 4.5, "add");
assert_eq!(pick(&mut s, 6.5, "add")["selection"], format!(r#"left, "Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost")));
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 7.5, "y": 0.5})).unwrap()["name"], "left");
let r = pick(&mut s, 0.0, "remove");
assert_eq!(r["selection"], format!(r#""Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost")));
assert_eq!(r["selected"], false);
// The text parses back to exactly the picked IDs.
let manifest = aux_at(Tick::ZERO).manifests[0].1.clone();
let got = effectcraft_effects::selection_hashes(&selection(&s), Some(&manifest));
assert_eq!(got, [COMMA, "star*", "ghost"].map(crypto_hash));
assert_eq!(s.history.undo.len(), steps + 5);
s.execute("edit.undo", json!({})).unwrap();
assert!(selection(&s).starts_with("left, "), "{}", selection(&s));
pick(&mut s, 2.0, "replace");
assert_eq!(selection(&s), r#""Green, \"Glossy\"""#);
// The same selection with other spacing: no change, no undo step.
s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": " \"Green, \\\"Glossy\\\"\" "})).unwrap();
let n = s.history.undo.len();
assert_eq!((pick(&mut s, 2.0, "replace")["changed"].as_bool(), s.history.undo.len()), (Some(false), n));
}
#[test]
fn bad_points_layers_and_modes_are_errors_and_change_nothing() {
let mut s = session();
let at = |s: &mut Session, x: f64, y: f64| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": y}));
for (x, y) in [(-0.1, 1.0), (8.0, 1.0), (1.0, 4.0), (1.0, 3.5), (1e300, -1e300)] {
assert!(at(&mut s, x, y).is_err(), "{x},{y}");
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": x, "y": y})).unwrap(), Value::Null);
}
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "mode": "toggle"})).is_err());
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1})).is_err());
for scale in [0.0, -1.0, 2.0] {
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "scale": scale})).is_err(), "{scale}");
}
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 2, "x": 1, "y": 1})).is_err(), "not a Cryptomatte effect");
assert_eq!(selection(&s), "");
// A layer without aux channels.
s.execute("layer.newSolid", json!({"name": "S"})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let e = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).unwrap_err().to_string();
assert!(e.contains("no Cryptomatte channels"), "{e}");
// An expression-driven Selection would ignore the edit.
let mut s = session();
s.execute_checked("prop.setExpression", json!({"path": "effects/#1/selection", "expression": "\"right\""})).unwrap();
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).is_err());
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 1, "y": 1})).unwrap()["selected"], false);
}
/// The pick reads the frame the render shows: Time Remap's source time, not the layer time.
#[test]
fn time_remap_picks_the_shown_source_frame() {
let mut s = session();
assert_eq!(pick(&mut s, 0.5, "replace")["name"], "left");
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap();
assert_eq!(pick(&mut s, 0.5, "replace")["name"], "late");
}
/// A precomp of a 3D comp: the generated CryptoObject pass names its layers.
#[test]
fn picks_on_a_precomp_of_a_3d_comp() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap();
let l = s.execute("layer.newSolid", json!({"name": "Red, Ball", "width": 40, "height": 20})).unwrap()["layer"].clone();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap();
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 20, "y": 10})).unwrap();
assert_eq!((r["name"].as_str(), r["selection"].as_str()), (Some("Red, Ball"), Some("\"Red, Ball\"")), "{r}");
}
/// The matte the rendered Cryptomatte effect gives the picked ID at the buffer pixel under
/// `{x, y}`: the pick has to name the ID the effect's own input buffer samples there.
fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (String, f32) {
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale, "mode": "replace"})).unwrap();
let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().position(|g| g.match_id == "ec.3d.cryptomatte").unwrap() + 1;
s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap();
let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap());
let layer = &comp.layers[0];
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None };
let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap();
let (px, py) = buf.to_px([x, y]);
let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0];
(r["name"].as_str().unwrap_or("?").to_string(), m)
}
/// Between 3/4 and full resolution footage keeps its native pixels: at 0.8 the effect samples
/// layer pixel 2 (the comma name, 0.7) at x = 2.2, not the 0.8-grid pixel snapped back to 1.
#[test]
fn pick_at_scale_0_8_matches_the_native_buffer_the_effect_renders() {
let mut s = session();
let (name, m) = rendered_matte_of_pick(&mut s, 2.2, 1.5, 0.8);
assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there");
assert_eq!(name, COMMA);
}
/// A half-size proxy renders a buffer at 0.5 layer pixels per pixel even at full resolution:
/// x = 6.5 lies in buffer pixel 3, which samples layer x = 7 (the `left` / `right` tie).
#[test]
fn pick_on_a_proxy_matches_the_proxy_sized_buffer() {
let mut s = session();
let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0;
s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/crypto_proxy.exr"})).unwrap();
let (name, m) = rendered_matte_of_pick(&mut s, 6.5, 1.5, 1.0);
assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there");
assert_eq!(name, "left");
}
/// 1920-px footage with `left` up to x = 120 and `right` after it, and a 104-px proxy.
struct Edge;
impl FootageSource for Edge {
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>> {
let (w, h) = (1920usize, 40usize);
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
let ids = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < 120 { "left" } else { "right" }))).collect();
aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])];
aux.manifests = vec![("CryptoObject".into(), ["left", "right"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())];
Some(Arc::new(aux))
}
}
/// Proxy buffer pixel 6's centre maps to layer x = 6.5 / (104 / 1920): 120.0 by division, but
/// 119.99999999999999 by the reciprocal the effect multiplies with. The pick must agree.
#[test]
fn pick_maps_pixel_centres_bit_exactly_as_the_effect() {
let mut s = Session { footage: Arc::new(Edge), ..Default::default() };
let at = |path: &str, width| Footage {
path: path.into(),
kind: FootageKind::Video,
width,
height: 40,
has_video: true,
frame_rate: FrameRate::from_f64(24.0),
duration: Tick::from_seconds_f64(2.0),
..Default::default()
};
s.importer = Some(Arc::new(EdgeProbe(at("/renders/edge.exr", 1920), at("/renders/edge_proxy.exr", 104))));
let item = s.execute("file.import", json!({"paths": ["/renders/edge.exr"]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "C", "width": 1920, "height": 40, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
s.execute("file.setProxy", json!({"item": item, "path": "/renders/edge_proxy.exr"})).unwrap();
let (name, m) = rendered_matte_of_pick(&mut s, 118.0, 5.0, 1.0);
assert_eq!((name.as_str(), m), ("left", 1.0));
}
struct EdgeProbe(Footage, Footage);
impl crate::Importer for EdgeProbe {
fn probe(&self, path: &str) -> Result<Footage, String> {
Ok(if path.contains("proxy") { self.1.clone() } else { self.0.clone() })
}
}
/// Footage whose decoded frames are `num / den` of the probed size (a file re-rendered at another
/// resolution, a relinked file): the render stretches them over the layer.
struct OffNominal(u32, u32);
impl FootageSource for OffNominal {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width * self.0 / self.1, f.height * self.0 / self.1)))
}
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
Some(Arc::new(aux_at(t)))
}
}
/// Points along rows `ys` where the picked ID isn't lit in the rendered matte under the point.
fn mismatches(s: &mut Session, scales: &[f64], xs: impl Iterator<Item = f64> + Clone, ys: &[f64]) -> Vec<String> {
let mut bad = vec![];
for &scale in scales {
for (x, &y) in ys.iter().flat_map(|y| xs.clone().map(move |x| (x, y))) {
if s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale})).unwrap().is_null() {
continue;
}
let (name, m) = rendered_matte_of_pick(s, x, y, scale);
if m < 0.5 {
bad.push(format!("scale {scale} ({x:.2}, {y}): picked {name:?}, matte {m}"));
}
}
}
bad
}
const PREVIEW_SCALES: [f64; 7] = [1.0, 0.8, 0.75, 0.74, 0.5, 0.33, 0.25];
/// The pick follows the decoded frame's real size, not the footage's nominal one.
#[test]
fn pick_matches_the_render_when_frames_decode_off_nominal_size() {
for (num, den) in [(2, 1), (1, 2), (3, 4)] {
let mut s = Session { footage: Arc::new(OffNominal(num, den)), importer: Some(Arc::new(Probe)), ..Default::default() };
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..80).map(|i| i as f64 * 0.1 + 0.05), &[0.3, 1.7, 2.6]);
assert!(bad.is_empty(), "decode {num}/{den}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
}
}
/// A precomp of a 3D comp, with and without Preserve Resolution (drawn at full size below full
/// resolution), behind a disabled effect: the pick names the ID the rendered matte shows.
#[test]
fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() {
for preserve in [false, true] {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 2})).unwrap();
let a = s.execute("layer.newSolid", json!({"name": "A", "width": 40, "height": 20})).unwrap()["layer"].clone();
let b = s.execute("layer.newSolid", json!({"name": "B", "width": 7, "height": 20})).unwrap()["layer"].clone();
s.execute_checked("prop.set", json!({"layer": b, "path": "transform/position", "value": [3.5, 10, -10]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
let inner = *s.project.items.iter().find(|(_, i)| i.name == "Inner").unwrap().0;
s.execute_checked("comp.settings", json!({"comp": inner.0, "preserveResolution": preserve})).unwrap();
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
s.execute_checked("effect.toggle", json!({"effect": 1, "value": false})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..93).map(|i| i as f64 * 0.15 + 0.05), &[10.0]);
assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
}
}
+21
View File
@@ -772,6 +772,27 @@ fn preset_round_trips_effect_properties_by_match_name() {
}
}
/// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
/// `essential.list` reports.
#[test]
fn add_key_resolves_effect_match_name_paths() {
let (mut s, a, _) = comp_with_two_solids();
apply(&mut s, a, "Slider Control");
apply(&mut s, a, "Slider Control");
let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value {
KV::Scalar(v) => v,
ref other => panic!("not a scalar: {other:?}"),
};
for (path, n, value) in [("effects/ec.control.slider/slider", 0, 30.0), ("effects/ec.control.slider#2/slider", 1, 70.0)] {
let reply = s.execute("prop.addKey", json!({"layer": a, "path": path, "time": 0, "value": value})).unwrap();
assert_eq!(reply, json!(1), "{path}");
let got = s.execute("prop.get", json!({"layer": a, "path": path, "time": 0})).unwrap();
assert_eq!(got["value"], json!(value), "{path}");
assert_eq!(got["keys"].as_array().unwrap().len(), 1, "{path}");
assert_eq!(slider(&s, n), 0.0, "the static value stays put; the key carries {value}");
}
}
/// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path,
/// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect.
#[test]
+4 -1
View File
@@ -57,7 +57,10 @@ fn default_layer_times_are_preserved_and_outside_keys_warn() {
assert!(selected.get("warning").is_some());
let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap();
assert_seconds(&moved["compTime"], 20.0);
assert!(moved.get("warning").is_some(), "out point is exclusive");
assert!(moved.get("warning").is_none(), "a key on the out point is accepted");
let past = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": 20.1, "timeBase": "comp", "value": 45})).unwrap();
assert_seconds(&past["compTime"], 20.1);
assert!(past.get("warning").is_some(), "a key past the out point warns");
}
#[test]
+45
View File
@@ -613,6 +613,51 @@ fn time_set_reads_frames_style_digits_as_a_frame_count() {
assert_eq!(r["display"], "00100");
}
/// Project Settings ▸ Time Display Style ▸ Frames ▸ Frame Count: Start at 0 / Start at 1
/// (`app.project.framesCountType`), undoable and saved with the project (#1807).
#[test]
fn project_settings_frame_count_starts_at_one() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Count", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap();
assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012");
s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap();
assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00013");
for bad in [json!(2), json!(-1), json!("one")] {
assert!(s.execute("file.projectSettings", json!({"frameStart": bad})).is_err(), "{bad}");
}
assert_eq!(s.project.settings.frame_start, 1);
let dir = std::env::temp_dir().join(format!("ec-frame-start-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("count.ecproj").to_string_lossy().to_string();
s.execute("file.save", json!({"path": path})).unwrap();
let mut reopened = Session::default();
reopened.execute("file.open", json!({"path": path})).unwrap();
let _ = std::fs::remove_dir_all(dir);
assert_eq!(reopened.project.settings.frame_start, 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012");
}
/// A Duration is a count of frames: Start at 1 and the comp's Start Timecode number positions,
/// not lengths (#1807).
#[test]
fn durations_ignore_the_frame_count_and_the_start_timecode() {
use crate::commands::time::display_duration;
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Len", "width": 64, "height": 64, "frameRate": 30, "duration": 10, "startTimecode": "0:00:01:00"})).unwrap();
s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap();
let comp = s.active_comp().unwrap().clone();
let len = comp.frame_rate.tick_of(30);
for (style, want) in [("frames", "00030"), ("feet35", "0001+14"), ("timecode", "0:00:01:00")] {
s.execute("file.projectSettings", json!({"timeDisplay": style})).unwrap();
assert_eq!(display_duration(&s, &comp, len), want, "{style}");
}
}
#[test]
fn separate_dimensions_split_and_rejoin() {
let (mut s, l) = setup();
+18
View File
@@ -1246,6 +1246,24 @@ impl<'g> Enc<'g> {
);
}
/// A `w` × `h` image showing `src` with its pixel (`x`, `y`) at the top-left, transparent
/// where `src` doesn't reach (Image::crop).
pub fn crop(&mut self, src: &GpuImage, x: i64, y: i64, w: u32, h: u32) -> GpuImage {
let dst = self.image(w, h);
let span = |o: i64, n: u32, len: u32| {
let (a, b) = (o.max(0), (o + n as i64).min(len as i64));
(b > a).then(|| (a as u32, (a - o) as u32, (b - a) as u32))
};
let (Some((sx, dx, cw)), Some((sy, dy, ch))) = (span(x, w, src.width), span(y, h, src.height)) else { return dst };
let enc = self.encoder();
enc.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d { x: sx, y: sy, z: 0 }, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x: dx, y: dy, z: 0 }, aspect: wgpu::TextureAspect::All },
wgpu::Extent3d { width: cw, height: ch, depth_or_array_layers: 1 },
);
dst
}
/// Submit recorded work.
pub fn submit(&mut self) {
if let Some((enc, id)) = self.enc.take() {
+1
View File
@@ -252,6 +252,7 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> u12
k.u64(e.effect_index as u64);
k.debug(&e.working_space);
k.u64(e.working_linear as u64);
k.u64(e.float_depth as u64);
k.debug(&e.shutter);
}
k.finish()
+1 -1
View File
@@ -388,7 +388,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
// ---------------------------------------------------------------- per-pixel
fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse(ctx.params.s(id)));
let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse_natural(ctx.params.s(id)));
if parsed.iter().all(Option::is_none) {
return Some(b);
}
+1 -2
View File
@@ -90,11 +90,10 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
"ec.color.blackwhite" => {
let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0);
let tc = pr.color("tintColor");
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
p.u[0] = [3, pr.b("tint") as u32, 0, 0];
p.f[0] = [w[0], w[1], w[2], w[3]];
p.f[1] = [w[4], w[5], 0.0, 0.0];
p.f[2] = [th, ts, 0.0, 0.0];
p.f[2] = [tc[0], tc[1], tc[2], 0.0];
}
"ec.color.tritone" => {
p.u[0][0] = 4;
+2 -2
View File
@@ -353,10 +353,10 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0];
p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1));
}
let blend = ctx.params.f("blend").max(1.0);
let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale);
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0];
p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
run(e, "gen_fourcolor", &p, b, None, None)
}
+1 -7
View File
@@ -637,13 +637,7 @@ fn motion_tile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let mirror = ctx.params.b("mirrorEdges");
let phase = ctx.params.f("phase") / 360.0;
let hshift = ctx.params.b("horizontalPhaseShift");
let (nw, nh, noff) = if ctx.adjustment {
(b.img.width, b.img.height, b.offset)
} else {
let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s);
let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s);
((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s])
};
let (nw, nh, noff) = effectcraft_effects::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s);
if !e.g.fits(nw, nh) {
return None;
}
+2
View File
@@ -461,3 +461,5 @@ mod tests_fx_warp;
mod tests_init;
#[cfg(test)]
mod tests_particles;
#[cfg(test)]
mod tests_wgsl_grammar;
+94 -20
View File
@@ -179,7 +179,41 @@ fn equirect_uv(d: vec3<f32>, rot: f32) -> vec2<f32> {
return vec2<f32>(u - floor(u), v);
}
fn shadow_factor(m: u32, p: vec3<f32>, n_l: f32) -> f32 {
fn shadow_texel_at(offset: u32, sz: i32, x: i32, y: i32) -> f32 {
if (x < 0 || y < 0 || x >= sz || y >= sz) {
return 1e30; // outside the map: the light was never blocked there (finite stand-in for the CPU path's infinity sentinel)
}
return shadow_texels[offset + u32(y * sz + x)];
}
fn tap_hash(x: i32, y: i32) -> u32 {
var h: u32 = (u32(x) * 0x9E3779B9u) ^ (u32(y) * 0x85EBCA6Bu);
h = h ^ (h >> 16u);
h = h * 0x2545F491u;
h = h ^ (h >> 15u);
return h;
}
const PCF_MAX_TAPS: i32 = 10;
const PLANE_SLOPE_MAX: f32 = 64.0;
// shade::shadow_factor's `open`: 1 where texel (x, y) does not occlude the receiver
// plane (depth slope -t in texel footprints per texel) at the texel's centre; the
// perspective compare runs on inverse depth (iz + g·iq), which is linear across the map.
fn shadow_open(offset: u32, sz: i32, x: i32, y: i32, ortho: bool, z: f32, texel: f32, bias: f32, t: vec2<f32>, u: f32, v: f32, iz: f32, iq: f32) -> f32 {
let s = shadow_texel_at(offset, sz, x, y) + bias;
let g = t.x * (f32(x) + 0.5 - u) + t.y * (f32(y) + 0.5 - v);
var lit: bool;
if (ortho) {
lit = z - g * texel <= s;
} else {
let inv = iz + g * iq;
lit = inv <= 0.0 || inv * s >= 1.0;
}
return select(0.0, 1.0, lit);
}
fn shadow_factor(m: u32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 {
let r0 = shadows[m];
let r1 = shadows[m + 1u];
let r2 = shadows[m + 2u];
@@ -205,28 +239,66 @@ fn shadow_factor(m: u32, p: vec3<f32>, n_l: f32) -> f32 {
}
let z = q.z;
let texel = select(abs(z) / focal, 1.0 / focal, ortho);
let bias = extra.x * texel * (1.0 + extra.y) * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0)));
let step = extra.y / 2.0;
// CPU mirror (shade::shadow_factor), step for step: slope-scaled bias at texel
// scale (independent of the PCF radius extra.y; a diffusion-scaled bias lifts
// shadows off their casters), each texel compared against the receiver plane's
// depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid
// jittered by an integer hash (bit-identical across backends) of the sub-texel
// cell and tap index, and bilinear compares of the four nearest texel centres.
// extra.y is the wanted penumbra in output pixels; dividing by the receiver's
// texel footprint keeps it in output pixels at any map resolution, and the reach
// is at least two texels so hard edges ramp over texel steps instead of showing them.
let bias = extra.x * texel * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0)));
let sz = i32(size);
let nv = vec3<f32>(dot(r0.xyz, ng), dot(r1.xyz, ng), dot(r2.xyz, ng));
var den0 = nv.z;
if (!ortho) {
den0 = nv.x * ((u - c) / focal) + nv.y * ((v - c) / focal) + nv.z;
}
var t = vec2<f32>(0.0, 0.0);
if (den0 < -1e-6) {
t = vec2<f32>(clamp(nv.x / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), clamp(nv.y / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX));
}
let iz = 1.0 / z;
let iq = 1.0 / (focal * z);
var reach = clamp(extra.y / texel, 2.0, 256.0);
if (!(reach < 1e30)) {
reach = 1.0;
}
let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS);
let step = reach / f32(n - 1);
let half = f32(n - 1) * 0.5;
let cx = i32(floor(u * 8.0));
let cy = i32(floor(v * 8.0));
var lit = 0.0;
for (var j = -2; j <= 2; j = j + 1) {
for (var i = -2; i <= 2; i = i + 1) {
let x = i32(floor(u + f32(i) * step));
let y = i32(floor(v + f32(j) * step));
if (x < 0 || y < 0 || x >= sz || y >= sz) {
lit = lit + 1.0;
continue;
}
let d = shadow_texels[offset + u32(y * sz + x)];
if (z - bias <= d) {
lit = lit + 1.0;
}
for (var j = 0; j < n; j = j + 1) {
for (var i = 0; i < n; i = i + 1) {
let h = tap_hash(cx + i * 7919, cy + j * 7919);
let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;
let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5;
let su = u + ((f32(i) - half) + jx) * step;
let sv = v + ((f32(j) - half) + jy) * step;
// Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the
// four is compared against the receiver plane at that centre.
let tu = su - 0.5;
let tv = sv - 0.5;
let fx = tu - floor(tu);
let fy = tv - floor(tv);
let x0 = i32(floor(tu));
let y0 = i32(floor(tv));
let h00 = shadow_open(offset, sz, x0, y0, ortho, z, texel, bias, t, u, v, iz, iq);
let h10 = shadow_open(offset, sz, x0 + 1, y0, ortho, z, texel, bias, t, u, v, iz, iq);
let h01 = shadow_open(offset, sz, x0, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq);
let h11 = shadow_open(offset, sz, x0 + 1, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq);
let a = h00 + (h10 - h00) * fx;
let b = h01 + (h11 - h01) * fx;
lit = lit + a + (b - a) * fy;
}
}
return lit / 25.0;
return lit / f32(n * n);
}
fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32, p: vec3<f32>, n_l: f32) -> f32 {
fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 {
if (shadow < 0) {
return 1.0;
}
@@ -242,7 +314,7 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32,
face = select(5u, 4u, d.z > 0.0);
}
}
let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, n_l);
let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, ng, n_l);
return 1.0 - darkness * (1.0 - f);
}
@@ -330,7 +402,7 @@ fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32
if (n_l <= 0.0) {
continue;
}
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n_l));
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n, n_l));
}
return amount;
}
@@ -399,6 +471,8 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
if (!front) {
n = -n;
}
// Shadow lookups follow the geometric surface, not the normal map.
let ng = n;
let tex_n = i32(m3.z);
if (tex_n >= 0) {
let t = sample_tex(tex_n, i.uv);
@@ -446,7 +520,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
}
var sh = 1.0;
if (receives_shadows) {
sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, n_l);
sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, ng, n_l);
}
let b = brdf(rgb, metallic, rough, spec_k, diff_k, n, v, to_l);
acc = acc + b * l2.xyz * k * sh;
+30 -3
View File
@@ -100,11 +100,36 @@ fn fxc_sc_weights(c0: vec3<f32>) -> array<f32, 9> {
return w;
}
// Black & White's Tint (effects::color_fx::bw_tint): the tint's hue and saturation at the
// gray's luminosity (Color blend, weights 0.3 / 0.59 / 0.11, channels clipped toward the gray).
// The weights are effects::BW_TINT_LUMA (checked by the test shader_constants_match_the_cpu).
fn fxc_bw_tint(tint: vec3<f32>, gray: f32) -> vec3<f32> {
if (!(gray > 0.0)) {
return vec3<f32>(0.0);
}
if (gray >= 1.0) {
return vec3<f32>(gray);
}
let w = vec3<f32>(0.3, 0.59, 0.11);
let c = tint + vec3<f32>(gray - dot(tint, w));
let l = dot(c, w);
let n = min(min(c.x, c.y), c.z);
let x = max(max(c.x, c.y), c.z);
var o = c;
if (n < 0.0 && l - n > 1e-9) {
o = vec3<f32>(l) + (o - vec3<f32>(l)) * l / (l - n);
}
if (x > 1.0 && x - l > 1e-9) {
o = vec3<f32>(l) + (o - vec3<f32>(l)) * (1.0 - l) / (x - l);
}
return o;
}
// u[0].x = op:
// 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity
// 2 Vibrance f[0] = (vibrance, saturation)
// 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas);
// u[0].y = tint; f[2] = (tint hue, tint saturation)
// u[0].y = tint; f[2] = tint colour
// 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend
// 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend)
// 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome
@@ -159,7 +184,7 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
let gray = max(mn + (mx - mn) * wt, 0.0);
var o = vec3<f32>(gray);
if (P.u[0].y != 0u) {
o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0));
o = fxc_bw_tint(P.f[2].xyz, gray);
}
px = vec4<f32>(o * a, a);
}
@@ -167,7 +192,9 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
case 4u: {
if (a > 0.0) {
let c = px.xyz / a;
let l = clamp(luminance(c), 0.0, 1.0);
// Rec. 601 weights, as effects::color_fx::tritone: effects::LUMA_601 (checked by
// the test shader_constants_match_the_cpu).
let l = clamp(dot(c, vec3<f32>(0.299, 0.587, 0.114)), 0.0, 1.0);
var t: vec3<f32>;
if (l < 0.5) {
t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0);
+1 -1
View File
@@ -500,7 +500,7 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3<u32>) {
let dx = f32(p.x) + 0.5 - q.x;
let dy = f32(p.y) + 0.5 - q.y;
let d2 = dx * dx + dy * dy;
let w = 1.0 / pow(d2 / bl + 1e-6, 1.5);
let w = 1.0 / (d2 + bl);
acc += P.f[4 + i].xyz * w;
wsum += w;
}
+12 -16
View File
@@ -62,6 +62,12 @@ fn fxk_range_components(space: u32, c: vec3<f32>) -> vec3<f32> {
}
// keying::matte_levels.
// Luma Key / Extract luminance: Rec. 601, effects::LUMA_601 (checked by the test
// shader_constants_match_the_cpu).
fn fxk_key_luma(c: vec3<f32>) -> f32 {
return dot(c, vec3<f32>(0.299, 0.587, 0.114));
}
fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 {
var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0);
t = powz(t, 1.0 / max(gamma, 0.01));
@@ -195,27 +201,17 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3<u32>) {
o = vec4<f32>(select(1.0, 0.0, d <= f0.w));
}
case 2u: {
let l = luminance(c);
let l = fxk_key_luma(c);
let thr = f0.x;
let tol = f0.y;
var m: f32;
// As effects::keying::luma_key: Brighter / Darker cut hard at the threshold
// (Tolerance only shapes Similar / Dissimilar), as After Effects.
switch u0.y {
case 0u: {
if (tol > 0.0) {
m = clamp((thr + tol - l) / tol, 0.0, 1.0);
} else {
m = select(1.0, 0.0, l > thr);
}
}
case 0u: { m = select(1.0, 0.0, l > thr); }
case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); }
case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); }
default: {
if (tol > 0.0) {
m = clamp((l - (thr - tol)) / tol, 0.0, 1.0);
} else {
m = select(1.0, 0.0, l < thr);
}
}
default: { m = select(1.0, 0.0, l < thr); }
}
o = vec4<f32>(m);
}
@@ -241,7 +237,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3<u32>) {
case 2u: { v = c.y; }
case 3u: { v = c.z; }
case 4u: { v = a; }
default: { v = luminance(c); }
default: { v = fxk_key_luma(c); }
}
v = 255.0 * v;
let bp = f0.x;
+1 -1
View File
@@ -538,7 +538,7 @@ fn fxn_typed_noise(x: f32, y: f32, z: f32, seed: u32, nt: u32) -> f32 {
let z0 = floor(z);
let tx = x - x0;
let ty = y - y0;
let fz = fade(z - z0);
let fz = z - z0;
let a = fxn_slice(i32(x0), i32(y0), i32(z0), tx, ty, seed, nt);
let b = fxn_slice(i32(x0), i32(y0), i32(z0) + 1, tx, ty, seed, nt);
return a + (b - a) * fz;
+11 -4
View File
@@ -20,6 +20,14 @@ fn fxt_rem360(x: f32) -> f32 {
}
// Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)).
// color_fx::levels_gamma: unclipped values below 0 mirror the curve.
fn fxt_gamma(t: f32, g: f32) -> f32 {
if (t >= 0.0) {
return powz(t, 1.0 / g);
}
return -powz(-t, 1.0 / g);
}
fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 {
let o = i * 6u;
if (data[o + 5u] != 0.0) {
@@ -32,7 +40,7 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 {
if (clip_w) {
t = min(t, 1.0);
}
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01));
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, max(data[o + 2u], 0.01));
}
// color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white).
@@ -44,8 +52,7 @@ fn fxt_level(o: u32, v: f32) -> f32 {
if (data[o + 6u] != 0.0) {
t = min(t, 1.0);
}
t = powz(max(t, 0.0), 1.0 / data[o + 2u]);
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t;
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, data[o + 2u]);
}
fn fxt_level_ident(o: u32) -> bool {
@@ -177,7 +184,7 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3<u32>) {
let aa = max(a, 0.0);
var nc: vec3<f32>;
for (var i = 0u; i < 3u; i++) {
nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw);
nc[i] = fxt_lv(0u, fxt_lv(i + 1u, c[i], cb, cw), cb, cw);
}
let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0);
o = vec4<f32>(nc * na, na);
+29 -18
View File
@@ -561,7 +561,7 @@ fn soft_linear_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
let wy = bspline(y + 0.5 - y0);
let a = soft_linear_slice(i32(x0), i32(y0), i32(z0), wx, wy, seed);
let b = soft_linear_slice(i32(x0), i32(y0), i32(z0) + 1, wx, wy, seed);
let n = a + (b - a) * fade(z - z0);
let n = a + (b - a) * (z - z0);
return 0.5 + (n - 0.5) * SOFT_LINEAR_GAIN;
}
@@ -605,7 +605,9 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
case 1u: {
if (a > 0.0) {
let c = straight(px);
let l = clamp(luminance(c), 0.0, 1.0);
// Rec. 601 weights, as After Effects: effects::LUMA_601 (checked by the test
// shader_constants_match_the_cpu).
let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0);
let blk = P.f[0].xyz;
let wht = P.f[1].xyz;
let t = blk + (wht - blk) * l;
@@ -623,14 +625,20 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
} else if (P.u[0].y == 2u) {
// Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681).
var o = c;
let g = max(1.0 + 0.53 * ct, 0.05);
// Negative settings invert the positive curves (reciprocal exponents).
var g = max(1.0 + 0.53 * abs(ct), 0.05);
if (ct < 0.0) {
g = 1.0 / g;
}
var e = exp2(1.24 * abs(br));
if (br < 0.0) {
e = 1.0 / e;
}
for (var i = 0; i < 3; i++) {
if (c[i] < 1.0) {
var v = max(c[i], 0.0);
if (br > 0.0) {
v = 1.0 - powz(1.0 - v, exp2(1.24 * br));
} else if (br < 0.0) {
v = powz(v, exp2(-1.32 * br));
if (br != 0.0) {
v = 1.0 - powz(1.0 - v, e);
}
if (ct != 0.0) {
if (v < 0.5) {
@@ -702,7 +710,12 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
if (P.u[0].z != 0u) {
t = min(t, 1.0);
}
t = powz(max(t, 0.0), 1.0 / g);
// color_fx::levels_gamma: unclipped values below 0 mirror the curve.
if (t >= 0.0) {
t = powz(t, 1.0 / g);
} else {
t = -powz(-t, 1.0 / g);
}
o[i] = ob + (ow - ob) * t;
}
px = vec4<f32>(o * a, a);
@@ -713,14 +726,16 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
let c = straight(px);
var o = vec3<f32>(0.0);
for (var i = 0; i < 3; i++) {
// Per-channel gain, offset, gamma; negative values are mirrored.
// Per-channel gain, offset, gamma; negative values are mirrored. The
// linear-light conversion is a pure 2.2 power: effects::EXPOSURE_LINEAR_GAMMA
// (checked by the test shader_constants_match_the_cpu).
if (P.u[0].y != 0u) {
let x = c[i] * P.f[0][i] + P.f[1][i];
o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]);
} else {
let lin = srgb_to_linear(max(c[i], 0.0));
let lin = sign(c[i]) * powz(abs(c[i]), 2.2);
let x = lin * P.f[0][i] + P.f[1][i];
o[i] = sign(x) * linear_to_srgb(powz(abs(x), 1.0 / P.f[2][i]));
o[i] = sign(x) * powz(powz(abs(x), 1.0 / P.f[2][i]), 1.0 / 2.2);
}
}
px = vec4<f32>(o * a, a);
@@ -753,17 +768,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
let c = P.f[0];
let invert = P.u[0].y != 0u;
let opacity = P.f[1].x;
// The fill colour at the layer's (or inverted) alpha times Opacity (color_fx::fill).
var fa = px.w;
if (invert) {
fa = 1.0 - px.w;
}
let f = c.xyz * fa;
let rgb = px.xyz + (f - px.xyz) * opacity;
var alpha = px.w;
if (invert) {
alpha = px.w + (fa - px.w) * opacity;
}
px = vec4<f32>(rgb, alpha);
fa = fa * opacity;
px = vec4<f32>(c.xyz * fa, fa);
}
case 8u: {
// effects::color2::map_ca: transparent pixels only change through the alpha curve.
+46
View File
@@ -880,6 +880,52 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
}
}
/// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB
/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`).
#[test]
fn gpu_tint_defaults_match_after_effects_greys() {
let Some(g) = gpu() else {
// Visible skip (EFFECTCRAFT_REQUIRE_GPU=1 makes a missing adapter fail instead).
eprintln!("gpu_tint_defaults_match_after_effects_greys: skipped: no GPU adapter");
return;
};
for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
let mut s = Scene::new(BitDepth::Bpc32);
s.p.settings.working_space = Some(ColorSpace::Srgb);
let mut l = s.solid(col, 97, 61);
s.effect(&mut l, "ec.color.tint", &[]);
s.push(l);
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
r.accel = Some(g);
let o = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame").get(48, 30);
let want = want / 255.0;
assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}");
}
}
/// WGSL has no way to read the CPU's constants, so the kernels repeat them as literals; this
/// ties each literal to its Rust source (runs without a GPU): Rec. 601 luma for Tint (`pointwise`
/// op 1), Tritone and Luma Key / Extract, Black & White's tint weights and Exposure's 2.2 power.
#[test]
fn shader_constants_match_the_cpu() {
use effectcraft_effects::{BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, LUMA_601};
let kernels = include_str!("shaders/kernels.wgsl");
let color = include_str!("shaders/fx_color.wgsl");
let key = include_str!("shaders/fx_key.wgsl");
let [r, g, b] = LUMA_601;
let dot = format!("dot(c, vec3<f32>({r}, {g}, {b}))");
assert!(kernels.contains(&format!("clamp({r} * c.x + {g} * c.y + {b} * c.z, 0.0, 1.0)")), "Tint luma in kernels.wgsl is not {LUMA_601:?}");
assert!(color.contains(&format!("clamp({dot}, 0.0, 1.0)")), "Tritone luma in fx_color.wgsl is not {LUMA_601:?}");
assert!(key.contains(&format!("return {dot};")), "fxk_key_luma in fx_key.wgsl is not {LUMA_601:?}");
let [r, g, b] = BW_TINT_LUMA;
assert!(color.contains(&format!("let w = vec3<f32>({r}, {g}, {b});")), "fxc_bw_tint weights are not {BW_TINT_LUMA:?}");
let gamma = EXPOSURE_LINEAR_GAMMA;
assert!(
kernels.contains(&format!("powz(abs(c[i]), {gamma})")) && kernels.contains(&format!("1.0 / {gamma})")),
"Exposure's power in kernels.wgsl is not {gamma}"
);
}
/// A layer whose bounds change every frame reads back at a new size each time: the staging
/// buffers kept for reuse stay within budget instead of growing for the whole render (#2081).
#[test]
+38
View File
@@ -167,6 +167,44 @@ fn gpu_matches_cpu_shadow_catcher() {
compare(&s, &g);
}
#[test]
fn gpu_matches_cpu_wide_penumbrae() {
// Both lights cast with wide Shadow Diffusion. Penumbrae cover much of the frame, so a
// backend drift in the PCF (jitter hash, tap grid, receiver-plane compare) would hide
// inside the scene-wide 1.5 % silhouette budget; here every shadowed pixel off a
// silhouette must match the CPU within 2/255.
let Some(g) = gpu() else { return };
let (mut p, cid) = scene_project();
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[1], "lightOptions/shadowDiffusion", Value::Scalar(48.0));
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(true));
set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0));
let s = scene(&p, cid);
let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster"));
for l in &mut p.comp_mut(cid).unwrap().layers[..2] {
set(l, "lightOptions/castsShadows", Value::Bool(false));
}
let unshadowed = adv::raster::render(&scene(&p, cid));
let w = cpu.width as usize;
let luma = |c: &[f32; 4]| (c[0] + c[1] + c[2]) / 3.0;
// Off a silhouette: the pixel and its four neighbours see one continuous surface.
let smooth = |i: usize| -> bool {
let (x, y) = (i % w, i / w);
let d = cpu.depth[i];
x > 0
&& y > 0
&& x + 1 < w
&& i + w < cpu.depth.len()
&& d.is_finite()
&& [i - 1, i + 1, i - w, i + w].iter().all(|&j| (cpu.depth[j] - d).abs() < 0.01 * d.abs().max(1.0))
};
let shadowed: Vec<usize> = (0..cpu.color.len()).filter(|&i| luma(&unshadowed.color[i]) - luma(&cpu.color[i]) > 0.01 && smooth(i)).collect();
let bad = shadowed.iter().filter(|&&i| (0..4).any(|k| (cpu.color[i][k] - gt.color[i][k]).abs() / cpu.color[i][k].abs().max(1.0) > 2.0 / 255.0)).count();
eprintln!("adv3d wide penumbrae: {bad}/{} shadowed pixels beyond 2/255", shadowed.len());
assert!(shadowed.len() > 2000, "the shadows cover a large area: {}", shadowed.len());
assert!(bad as f64 <= 0.002 * shadowed.len() as f64, "{bad}/{} shadowed pixels differ", shadowed.len());
}
#[test]
fn gpu_raster_perf_smoke() {
let Some(g) = gpu() else { return };
+41
View File
@@ -89,6 +89,8 @@ fn tiles_kaleida_repetile() {
"ec.stylize.motiontile",
&[("horizontalPhaseShift", on()), ("phase", n(33.0)), ("tileCenter", pt(30.0, 17.0)), ("tileHeight", n(55.0)), ("outputWidth", n(80.0))],
);
// Output under 100 % on an adjustment crops the comp below (#1727).
effect_case("ec.stylize.motiontile", &[("outputWidth", n(60.0)), ("outputHeight", n(300.0)), ("mirrorEdges", on())]);
effect_case("ec.stylize.cckaleida", &[]);
effect_case("ec.stylize.cckaleida", &[("mirroring", e(2)), ("rotation", n(20.0)), ("size", n(150.0))]);
effect_case("ec.stylize.ccrepetile", &[("expandRight", n(10.0)), ("expandUp", n(4.0))]);
@@ -225,3 +227,42 @@ fn transform_motion_blur() {
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::from_seconds_f64(0.3)), 0.0);
}
}
/// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the
/// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes
/// back onto the canvas as on the CPU (#1727).
#[test]
fn adjustment_motion_tile_output_then_transform() {
for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0), (60.0, 80.0, 100.0)] {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let bg = s.footage(97, 61);
s.push(bg);
let mut adj = s.solid([1.0, 1.0, 1.0], 97, 61);
adj.switches.adjustment = true;
s.effect(&mut adj, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]);
s.effect(&mut adj, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]);
s.push(adj);
let label = format!("adjustment Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
/// The same on a full-frame footage layer (#1991).
#[test]
fn layer_motion_tile_output_then_transform() {
for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0)] {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let mut l = s.footage(97, 61);
s.effect(&mut l, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]);
s.effect(&mut l, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]);
s.push(l);
let label = format!("layer Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
+191
View File
@@ -0,0 +1,191 @@
//! WGSL rules naga accepts but stricter front-ends (Tint, i.e. the browser's
//! WebGPU, which receives the WGSL source verbatim on wasm) reject.
//!
//! WGSL gives the bitwise operators `&`, `|` and `^` no precedence against any other
//! binary operator: `a * b ^ c` must be written `(a * b) ^ c`, and `a & b == c` as
//! `(a & b) == c`. Only a chain of the same bitwise operator may go unparenthesised.
use std::fs;
use std::path::Path;
#[derive(Clone, Copy, PartialEq)]
enum Tok<'a> {
Operand,
Open,
Close,
Op(&'a str),
/// A unary prefix (`!`, `~`): the operator after it cannot be binary.
Prefix,
/// Ends an expression: `,` `;` `{` `}` `:` `=` compound assignment, `->`, `@`.
Sep,
}
const BITWISE: [&str; 3] = ["&", "|", "^"];
/// Drop comments and template argument lists (`vec3<f32>`, `array<vec4<u32>>`), whose
/// angle brackets are not relational operators.
fn strip(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let b = src.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i..].starts_with(b"//") {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
} else if b[i..].starts_with(b"/*") {
i += 2;
while i + 1 < b.len() && !b[i..].starts_with(b"*/") {
out.push(if b[i] == b'\n' { '\n' } else { ' ' });
i += 1;
}
i += 2;
} else {
out.push(b[i] as char);
i += 1;
}
}
loop {
let mut changed = false;
let mut next = String::with_capacity(out.len());
let c: Vec<char> = out.chars().collect();
let mut i = 0;
while i < c.len() {
if c[i] == '<' && i > 0 && (c[i - 1].is_alphanumeric() || c[i - 1] == '_') {
let mut j = i + 1;
while j < c.len() && (c[j].is_alphanumeric() || c[j] == '_' || c[j] == ',' || c[j] == ' ') {
j += 1;
}
if j < c.len() && c[j] == '>' {
i = j + 1;
changed = true;
continue;
}
}
next.push(c[i]);
i += 1;
}
out = next;
if !changed {
return out;
}
}
}
/// `(line, message)` for every bitwise operator mixed with another binary operator at
/// the same parenthesis level.
fn violations(src: &str) -> Vec<(usize, String)> {
let s = strip(src);
let b = s.as_bytes();
let mut out = vec![];
// Binary operators seen at each open parenthesis level.
let mut levels: Vec<Vec<&str>> = vec![vec![]];
let mut prev = Tok::Sep;
let (mut i, mut line) = (0, 1);
const OPS: [&str; 18] = ["&&", "||", "==", "!=", "<=", ">=", "<<", ">>", "<", ">", "+", "-", "*", "/", "%", "&", "|", "^"];
while i < b.len() {
let ch = b[i];
if ch == b'\n' {
line += 1;
i += 1;
continue;
}
if ch.is_ascii_whitespace() {
i += 1;
continue;
}
let tok = if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'.' {
// Identifier, member access or number (`1e-6`, `0x9E3779B9u`).
let start = i;
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'.') {
if (b[i] == b'e' || b[i] == b'E')
&& b[start].is_ascii_digit()
&& !s[start..i].starts_with("0x")
&& i + 1 < b.len()
&& (b[i + 1] == b'-' || b[i + 1] == b'+')
{
i += 1;
}
i += 1;
}
Tok::Operand
} else if ch == b'(' || ch == b'[' {
i += 1;
levels.push(vec![]);
Tok::Open
} else if ch == b')' || ch == b']' {
i += 1;
if levels.len() > 1 {
levels.pop();
}
Tok::Close
} else if b[i..].starts_with(b"->") || b[i..].starts_with(b"++") || b[i..].starts_with(b"--") {
i += 2;
Tok::Sep
} else if matches!(ch, b',' | b';' | b'{' | b'}' | b':' | b'@') {
i += 1;
Tok::Sep
} else if let Some(op) = OPS.iter().find(|op| b[i..].starts_with(op.as_bytes())) {
i += op.len();
if b.get(i) == Some(&b'=') && !matches!(*op, "==" | "!=" | "<=" | ">=") {
i += 1;
Tok::Sep // compound assignment
} else {
Tok::Op(op)
}
} else if ch == b'=' {
i += 1;
Tok::Sep
} else {
i += 1;
Tok::Prefix
};
match tok {
Tok::Sep => {
if let Some(l) = levels.last_mut() {
l.clear();
}
}
Tok::Op(op) if matches!(prev, Tok::Operand | Tok::Close) => {
let level = levels.last_mut().expect("level");
let mixes = |a: &str, b: &str| a != b && (BITWISE.contains(&a) || BITWISE.contains(&b));
if let Some(other) = level.iter().find(|o| mixes(o, op)) {
out.push((line, format!("mixing '{other}' and '{op}' requires parentheses")));
}
level.push(op);
}
_ => {}
}
prev = tok;
}
out
}
#[test]
fn checker_flags_unparenthesised_bitwise_mixes() {
assert_eq!(violations("let h = u32(x) * 3u ^ u32(y) * 5u;").len(), 2);
assert_eq!(violations("let a = b & c == d;").len(), 1);
assert!(violations("let h = (u32(x) * 3u) ^ (u32(y) * 5u);").is_empty());
assert!(violations("let h = a ^ b ^ c; let m = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;").is_empty());
assert!(violations("let v: vec3<f32> = vec3<f32>(1e-6, -x, a < b); if (a < b && c > d) { h ^= h >> 16u; }").is_empty());
assert!(violations("var p = &x; let y = -a & b;").is_empty());
}
#[test]
fn shaders_parenthesise_bitwise_operators() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shaders");
let mut bad = vec![];
let mut files = 0;
for entry in fs::read_dir(&dir).expect("shader dir") {
let path = entry.expect("entry").path();
if path.extension().is_some_and(|e| e == "wgsl") {
files += 1;
let src = fs::read_to_string(&path).expect("shader source");
for (line, msg) in violations(&src) {
bad.push(format!("{}:{line}: {msg}", path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()));
}
}
}
assert!(files > 10, "found the shaders: {files}");
assert!(bad.is_empty(), "WGSL a strict front-end rejects:\n{}", bad.join("\n"));
}
+6 -4
View File
@@ -26,9 +26,7 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick
return None;
}
let ctx = r.eval_ctx(comp_id, t)?;
let s = r.opts.scale;
let w = ((ctx.comp.width as f64 * s).round() as u32).max(1);
let h = ((ctx.comp.height as f64 * s).round() as u32).max(1);
let (w, h) = effectcraft_render::frame_size(ctx.comp.width, ctx.comp.height, r.opts.scale);
if r.depth() > 16 || !e.g.fits(w, h) {
return None;
}
@@ -357,7 +355,11 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv
};
let mut fx = crate::effects::GpuFx::new(e, below, [0.0; 2], r.opts.scale, pipe.levels);
r.run_effects_on(ctx, layer, true, &mut fx);
let (mut adjusted, _, _) = fx.finish()?;
let (mut adjusted, offset, _) = fx.finish()?;
// An effect that changed the frame (Motion Tile's Output size): back onto the canvas.
if let Some((dx, dy)) = effectcraft_effects::adjustment_crop(offset, [0.0; 2], (adjusted.width, adjusted.height), (w, h)) {
adjusted = e.crop(&adjusted, dx, dy, w, h);
}
if adjusted.width != w || adjusted.height != h {
return Some(());
}
+127
View File
@@ -0,0 +1,127 @@
//! The Cryptomatte eyedropper on real OpenEXR footage (#473): `effect.pickCryptomatte` reads the
//! file's Cryptomatte ranks and manifest, edits the Selection, and the rendered matte follows,
//! including undo.
use effectcraft_engine::Session;
use effectcraft_engine::time::Tick;
use effectcraft_raster::channels3d::{crypto_float, crypto_hash};
use serde_json::json;
const RIGHT: &str = "Right, \"Box\"";
/// An 8 × 4 EXR: white RGBA, and a CryptoObject layer with `Left` in columns before `split` and
/// the comma-and-quotes name from there, both named in its manifest.
fn crypto_exr(tag: &str, split: usize) -> String {
use exr::prelude::*;
let (w, h) = (8usize, 4usize);
let ids: Vec<f32> = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < split { "Left" } else { RIGHT }))).collect();
let ch = |n: &str, v: Vec<f32>| AnyChannel::new(n, FlatSamples::F32(v));
let one = || vec![1.0f32; w * h];
let channels = AnyChannels::sort(
vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(),
);
let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels);
let manifest = json!({"Left": format!("{:08x}", crypto_hash("Left")), RIGHT: format!("{:08x}", crypto_hash(RIGHT))}).to_string();
for (k, v) in [("name", "CryptoObject".to_string()), ("manifest", manifest)] {
layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{k}").as_str()), AttributeValue::Text(Text::from(v.as_str())));
}
// A folder of its own: numbered siblings would import as an image sequence.
let dir = std::env::temp_dir().join(format!("ec-crypto-pick-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("crypto.exr");
Image::from_layer(layer).write().to_file(&path).unwrap();
path.to_string_lossy().to_string()
}
/// A session with the EXR as a layer of a 2 s comp, under a Cryptomatte effect.
fn footage(tag: &str, split: usize) -> (Session, String) {
let path = crypto_exr(tag, split);
let mut s = effectcraft_host::session();
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
s.execute("comp.new", json!({"name": "Crypto", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": r["items"][0]})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
(s, path)
}
/// The matte's alpha over the left and right halves.
fn matte(s: &Session) -> [f32; 2] {
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
[img.get(1, 1)[3], img.get(6, 2)[3]]
}
#[test]
fn picking_on_a_cryptomatte_exr_edits_the_selection_and_the_matte() {
let (mut s, path) = footage("edit", 4);
s.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": 1})).unwrap();
assert_eq!(matte(&s), [0.0, 0.0], "nothing selected");
let pick = |s: &mut Session, x: f64, mode: &str| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1, "mode": mode})).unwrap();
assert_eq!(pick(&mut s, 1.0, "replace")["selection"], "Left");
assert_eq!(matte(&s), [1.0, 0.0]);
let r = pick(&mut s, 6.0, "add");
assert_eq!((r["name"].as_str(), r["layer"].as_str()), (Some(RIGHT), Some("CryptoObject")), "{r}");
assert_eq!(r["selection"], r#"Left, "Right, \"Box\"""#);
assert_eq!(matte(&s), [1.0, 1.0], "the quoted name selects the right half");
pick(&mut s, 2.0, "remove");
assert_eq!(matte(&s), [0.0, 1.0]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(matte(&s), [1.0, 1.0]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(matte(&s), [1.0, 0.0]);
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
}
/// At half resolution a preview pixel shows the aux texel at its centre (layer x 3 for preview
/// pixel 1): `scale` makes the pick read that one, so what is clicked is what lights up.
#[test]
fn a_pick_at_preview_resolution_reads_the_pixel_the_preview_shows() {
let (mut s, path) = footage("half", 3);
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 2.5, "y": 1.5, "scale": 0.5})).unwrap();
assert_eq!(r["name"], RIGHT, "{r}");
let opts = effectcraft_engine::render::RenderOpts { scale: 0.5, ..Default::default() };
assert_eq!(s.render(s.active_comp_id().unwrap(), Tick::ZERO, opts).get(1, 0)[3], 1.0);
// At full resolution the same point is Left's pixel.
let r = s.execute_checked("effect.cryptomatteAt", json!({"effect": 1, "x": 2.5, "y": 1.5})).unwrap();
assert_eq!(r["name"], "Left");
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
}
/// A keyframed Selection picked at `time` is edited at that time, not at the current time.
#[test]
fn a_keyframed_selection_is_edited_where_it_was_read() {
let (mut s, path) = footage("key", 4);
for t in [0.0, 1.0] {
s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": format!("<{:08x}>", crypto_hash(RIGHT))})).unwrap();
}
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 0.5, "y": 1.5, "mode": "add", "time": 1.0})).unwrap();
assert_eq!((r["changed"].as_bool(), r["selected"].as_bool()), (Some(true), Some(true)), "{r}");
let sel = |s: &mut Session, t: f64| {
let r = s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t})).unwrap();
r["selected"].as_bool()
};
assert_eq!((sel(&mut s, 0.0), sel(&mut s, 1.0)), (Some(false), Some(true)), "only the key at 1 s gains Left");
let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap());
}
/// A precomp whose 3D layer an expression moves: the pick finds it where the render keys it
/// (the pick's aux pass runs the session's expressions).
#[test]
fn a_pick_on_a_precomp_follows_expressions() {
let mut s = effectcraft_host::session();
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap()["comp"].clone();
let l = s.execute("layer.newSolid", json!({"name": "Ball", "width": 6, "height": 6})).unwrap()["layer"].clone();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute_checked("prop.setExpression", json!({"layer": l, "path": "transform/position", "expression": "[33, 10, 0]"})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap();
s.execute("layer.addItem", json!({"item": inner})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 33.5, "y": 10.5})).unwrap();
assert_eq!(r["selection"], "Ball", "{r}");
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
assert_eq!((img.get(33, 10)[3], img.get(20, 10)[3]), (1.0, 0.0));
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 20.5, "y": 10.5})).unwrap(), serde_json::Value::Null);
}
+1
View File
@@ -1,6 +1,7 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod background_presets;
mod cryptomatte_pick;
mod determinism;
mod exr_layers;
mod footage_reload;
+42
View File
@@ -1065,6 +1065,48 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
// Regression test for #382 (#274): resampling footage audio divided by the requested mix
// rate, so a rate of 0 panicked (division by zero); the read must come back instead.
#[test]
fn decoding_audio_at_a_zero_mix_rate_does_not_divide_by_zero() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-zero-rate");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// One second of a 440 Hz tone, 44.1 kHz mono 16-bit PCM: not the 48 kHz mix rate.
let native = 44_100u32;
let pcm: Vec<u8> =
(0..native).flat_map(|n| (((n as f32 * 440.0 * std::f32::consts::TAU / native as f32).sin() * 8000.0) as i16).to_le_bytes()).collect();
let mut wav = b"RIFF".to_vec();
wav.extend((36 + pcm.len() as u32).to_le_bytes());
wav.extend(b"WAVEfmt ");
wav.extend(16u32.to_le_bytes());
wav.extend([1u16, 1].iter().flat_map(|v| v.to_le_bytes()));
wav.extend(native.to_le_bytes());
wav.extend((native * 2).to_le_bytes());
wav.extend([2u16, 16].iter().flat_map(|v| v.to_le_bytes()));
wav.extend(b"data");
wav.extend((pcm.len() as u32).to_le_bytes());
wav.extend(&pcm);
let path = dir.join("tone.wav");
std::fs::write(&path, &wav).unwrap();
let footage = Footage {
path: path.to_string_lossy().into(),
kind: FootageKind::Audio,
duration: Tick::from_seconds_f64(1.0),
has_audio: true,
..Default::default()
};
let pool = MediaPool::new();
// The file really is read, and resampled, at a normal rate...
let tone = pool.decode_audio(&footage, Tick::from_seconds_f64(0.1), 1024, 48_000);
assert!(tone.iter().any(|v| v.abs() > 0.05), "the tone is audible at 48 kHz");
// ...and a mix rate of 0 (which `audio_samples` itself turns away) returns a read of the
// requested length instead of dividing by it.
let degenerate = pool.try_decode_audio(&footage, Tick::from_seconds_f64(0.1), 64, 0).unwrap();
assert_eq!(degenerate.len(), 128);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn frame_index_loops_and_clamps() {
let r = FrameRate::FPS_30;
+47
View File
@@ -148,6 +148,31 @@ impl AuxChannels {
self.crypto_rank_planes(layer).iter().map(|(id, c)| (id[i], c[i])).collect()
}
/// The Cryptomatte layer an effect asking for `want` (`CryptoObject`, …) reads: an exact
/// match, else one ending in it (`ViewLayer.CryptoObject`), else the first layer.
pub fn resolve_crypto_layer(&self, want: &str) -> Option<String> {
let layers = self.crypto_layers();
layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).or(layers.first()).cloned()
}
/// The ID (its hash, `f32::to_bits`) with the most coverage at aux pixel `i` of Cryptomatte
/// layer `layer`, summed over the ranks it appears in. Ties go to the lower rank; IDs with
/// no positive coverage don't count. `None` when nothing covers the pixel.
pub fn crypto_id_at(&self, layer: &str, i: usize) -> Option<u32> {
let mut ids: Vec<(u32, f32)> = vec![];
for (id, cov) in self.crypto_rank_planes(layer) {
let (Some(id), Some(&c)) = (id.get(i), cov.get(i)) else { continue };
if c > 0.0 {
match ids.iter_mut().find(|(h, _)| *h == id.to_bits()) {
Some((_, sum)) => *sum += c,
None => ids.push((id.to_bits(), c)),
}
}
}
// `min_by` keeps the first of equal elements: a tie goes to the lower rank.
ids.into_iter().min_by(|a, b| b.1.total_cmp(&a.1)).map(|(h, _)| h)
}
/// Aux pixel index of a layer-space point (nearest; `None` outside).
#[inline]
pub fn index_at(&self, lx: f64, ly: f64) -> Option<usize> {
@@ -211,6 +236,28 @@ pub fn crypto_float(hash: u32) -> f32 {
mod tests {
use super::*;
/// The picked ID: most coverage (summed over ranks), ties to the lower rank, nothing when
/// no rank covers the pixel; layers resolve as the Cryptomatte effect does.
#[test]
fn crypto_id_at_takes_the_top_coverage_and_layers_resolve_by_suffix() {
let mut a = AuxChannels::new(4, 1, 1.0);
let f = |n: &str| crypto_float(crypto_hash(n));
let (x, y, z) = (f("x"), f("y"), f("z"));
for (c, v) in [("R", [x, x, x, x]), ("G", [0.3, 0.5, 0.0, 0.2]), ("B", [y, y, y, y]), ("A", [0.6, 0.5, 0.0, 0.3])] {
a.channels.push((format!("ViewLayer.CryptoObject00.{c}"), v.to_vec()));
}
for (c, v) in [("R", [z, z, z, x]), ("G", [0.1, 0.0, -1.0, 0.2])] {
a.channels.push((format!("ViewLayer.CryptoObject01.{c}"), v.to_vec()));
}
a.channels.push(("Other00.R".into(), vec![0.0; 4]));
let l = a.resolve_crypto_layer("CryptoObject").unwrap();
assert_eq!(l, "ViewLayer.CryptoObject");
assert_eq!(a.resolve_crypto_layer("CryptoMaterial").as_deref(), Some("Other"), "falls back to the first");
let at = |i| a.crypto_id_at(&l, i);
assert_eq!([at(0), at(1), at(2), at(3), at(9)], [Some(crypto_hash("y")), Some(crypto_hash("x")), None, Some(crypto_hash("x")), None]);
assert_eq!(AuxChannels::new(1, 1, 1.0).resolve_crypto_layer("CryptoObject"), None);
}
#[test]
fn murmur3_reference_values() {
// Published MurmurHash3_x86_32 test vectors.
+45 -22
View File
@@ -314,26 +314,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
fn aux(&self) -> Option<Arc<effectcraft_raster::AuxChannels>> {
match &self.layer.source {
LayerSource::Footage { item } => {
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?;
// Aux pixels per layer pixel (the layer is the footage at its nominal size).
if f.width > 0 && a.width > 0 {
let mut a = (*a).clone();
a.scale = a.width as f64 / f.width as f64;
return Some(Arc::new(a));
}
Some(a)
}
LayerSource::Comp { item } => {
if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) {
return None;
}
self.r.nested().comp_aux(*item, self.ctx.nested_time(self.layer)).map(Arc::new)
}
_ => None,
}
self.r.layer_aux(self.ctx, self.layer)
}
fn params_at(&self, layer_time: f64) -> Option<Params> {
@@ -1000,8 +981,7 @@ impl<'a> Renderer<'a> {
pub fn comp_frame_cpu(&self, comp_id: ItemId, t: Tick) -> Image {
let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) };
let s = self.opts.scale;
let w = ((comp.width as f64 * s).round() as u32).max(1);
let h = ((comp.height as f64 * s).round() as u32).max(1);
let (w, h) = frame_size(comp.width, comp.height, s);
// Region of interest on a comp with 3D layers: render the frame and crop it. A region
// reaching past the comp frame (the Extended Viewer) renders Classic 3D natively — the
// planes project through the offset camera — and Advanced 3D as the frame, padded.
@@ -1184,6 +1164,7 @@ impl<'a> Renderer<'a> {
effect_index: 0,
working_space: self.pipe.space,
working_linear: self.pipe.linear,
float_depth: self.pipe.levels.is_none(),
shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)),
};
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
@@ -2051,6 +2032,12 @@ impl<'a> Renderer<'a> {
color::convert(&mut below.img, &c);
}
let mut adjusted = self.apply_effects(ctx, layer, below, true);
// An effect that changed the frame (Motion Tile's Output size): back onto the canvas.
if let Some((dx, dy)) =
effectcraft_effects::adjustment_crop(adjusted.offset, o, (adjusted.img.width, adjusted.img.height), (canvas.width, canvas.height))
{
adjusted.img = adjusted.img.crop(dx, dy, canvas.width, canvas.height);
}
if let Some(c) = self.pipe.to_blend() {
color::convert(&mut adjusted.img, &c);
}
@@ -2111,6 +2098,34 @@ impl<'a> Renderer<'a> {
Some(three_d::compose::aux_pass(self, &ctx))
}
/// The auxiliary channels effects on `layer` read ([`EffectHost::aux`]): its multi-layer
/// OpenEXR footage's at the layer's source time (Time Remap included), or a precomp's
/// generated 3D channels at its nested time. `None` for other layers.
pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<effectcraft_raster::AuxChannels>> {
match &layer.source {
LayerSource::Footage { item } => {
let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None };
let a = self.footage.aux(*item, f, ctx.source_time(layer))?;
// Aux pixels per layer pixel (the layer is the footage at its nominal size). The
// planes are only copied when that changes the scale.
let scale = a.width as f64 / f.width as f64;
if f.width > 0 && a.width > 0 && a.scale != scale {
let mut a = (*a).clone();
a.scale = scale;
return Some(Arc::new(a));
}
Some(a)
}
LayerSource::Comp { item } => {
if self.depth > MAX_FX_DEPTH || self.project.comp_contains(*item, ctx.comp_id) {
return None;
}
self.nested().comp_aux(*item, ctx.nested_time(layer)).map(Arc::new)
}
_ => None,
}
}
/// Evaluation context of comp `comp_id` at comp time `t`.
pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option<EvalCtx<'a>> {
Some(self.ctx(comp_id, self.project.comp(comp_id)?, t))
@@ -2373,6 +2388,12 @@ pub fn extended_region(w: u32, h: u32, visible: [f64; 4], margin: f64) -> Option
Some([x0, y0, x1 - x0, y1 - y0])
}
/// The size of a `width` x `height` comp frame rendered at `scale` (both compositors, and the
/// frame size Composition Settings shows for a Resolution).
pub fn frame_size(width: u32, height: u32, scale: f64) -> (u32, u32) {
(((width as f64 * scale).round() as u32).max(1), ((height as f64 * scale).round() as u32).max(1))
}
pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t)
}
@@ -2430,6 +2451,8 @@ mod tests_mask_blur;
#[cfg(test)]
mod tests_mask_expansion;
#[cfg(test)]
mod tests_motion_tile;
#[cfg(test)]
mod tests_nested;
#[cfg(test)]
mod tests_paint;
+16
View File
@@ -254,6 +254,22 @@ fn effects_run_in_pipeline() {
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
}
/// After Effects 2026, sRGB working space (not linearised), Tint at its defaults on full-frame
/// solids: the effect sees the encoded solid colour and weights it Rec. 601.
#[test]
fn tint_in_an_srgb_working_space_matches_after_effects_greys() {
for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
let (mut p, cid, comp) = setup();
p.settings.working_space = Some(effectcraft_color::ColorSpace::Srgb);
let mut l = solid(&mut p, &comp, col, 200, 100);
add_effect(&mut p, &mut l, "ec.color.tint", [200.0, 100.0], &[]);
p.comp_mut(cid).unwrap().layers.push(l);
let o = render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 50);
let want = want / 255.0;
assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}");
}
}
/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place.
#[test]
fn card_dance_comp_camera_matches_the_default_view() {
+39 -28
View File
@@ -9,31 +9,31 @@ use effectcraft_time::{FrameRate, Tick};
use crate::{Accelerator, AutoKey, AutoPick, Backend, NoFootage, RenderOpts, Renderer};
/// An accelerator that takes `delay` per frame (or declines with `handle = false`) and keeps
/// Auto timing history.
struct TimedAccel {
delay: std::time::Duration,
/// An accelerator that returns a representative pixel (or declines) and keeps Auto history.
struct MockAccel {
handle: bool,
calls: AtomicUsize,
pick: AutoPick,
}
impl TimedAccel {
fn new(ms: u64, handle: bool) -> TimedAccel {
TimedAccel { delay: std::time::Duration::from_millis(ms), handle, calls: Default::default(), pick: Default::default() }
impl MockAccel {
/// Construct a renderer stand-in without making assumptions about scheduling or speed.
fn new(handle: bool) -> MockAccel {
MockAccel { handle, calls: Default::default(), pick: Default::default() }
}
/// Number of full-frame requests dispatched to this accelerator.
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
impl Accelerator for TimedAccel {
impl Accelerator for MockAccel {
fn name(&self) -> String {
"timed".into()
}
/// Count dispatched frames and return the same centre pixel as the CPU fixture.
fn comp_frame(&self, _r: &Renderer, _comp: ItemId, _t: Tick) -> Option<crate::Image> {
self.calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(self.delay);
// The centre of the comp, as the CPU draws it (a red solid).
self.handle.then(|| {
let mut img = crate::Image::new(200, 100);
@@ -52,6 +52,7 @@ impl Accelerator for TimedAccel {
}
}
/// A small red solid whose centre pixel is shared by the CPU and mock GPU fixtures.
fn project() -> (Project, ItemId) {
let mut p = Project::default();
p.settings.gpu_acceleration = true;
@@ -64,7 +65,7 @@ fn project() -> (Project, ItemId) {
}
/// Render `n` frames with `backend`; how many the accelerator was asked for.
fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) -> usize {
fn frames(p: &Project, cid: ItemId, a: &MockAccel, backend: Backend, n: usize) -> usize {
let before = a.calls();
for _ in 0..n {
let mut r = Renderer::new(p, &NoFootage, RenderOpts { backend, ..Default::default() });
@@ -76,50 +77,60 @@ fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize)
a.calls() - before
}
/// Drive the production selector with measured costs supplied by the test, not the OS clock.
fn measured_frames(pick: &AutoPick, key: AutoKey, cpu_ms: f64, gpu_ms: f64, n: usize) -> usize {
(0..n)
.filter(|_| {
let gpu = pick.choose(key);
pick.record(key, gpu, if gpu { gpu_ms } else { cpu_ms });
gpu
})
.count()
}
/// A slower GPU is periodically re-probed; explicit GPU and Software Only ignore that history.
#[test]
fn auto_backend_moves_light_comps_to_the_cpu() {
// A light comp on a slow accelerator: after the warm-up (three frames on
// each side) Auto renders on the CPU, re-measuring the accelerator now and then.
let (p, cid) = project();
// Several times the CPU's frame time (unoptimised test builds are slow).
let t0 = std::time::Instant::now();
for _ in 0..3 {
Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
}
let cpu_ms = t0.elapsed().as_millis() as u64 / 3;
let slow = TimedAccel::new((cpu_ms * 5).max(20), true);
let slow = MockAccel::new(true);
let key = AutoKey::new(cid, 1.0, false);
let n = 6 + 2 * AutoPick::REPROBE as usize;
let asked = frames(&p, cid, &slow, Backend::Auto, n);
let asked = measured_frames(&slow.pick, key, 1.0, 20.0, n);
assert!((3..=5).contains(&asked), "asked the slow accelerator {asked} times in {n} frames");
let s = slow.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap();
let s = slow.pick.stats(key).unwrap();
assert!(s.gpu_ms.unwrap() > s.cpu_ms.unwrap(), "{s:?}");
// Backend::Gpu ignores the history; Software Only never asks.
// Check renderer wiring with known history before a real measurement can change it.
assert_eq!(frames(&p, cid, &slow, Backend::Auto, 1), 0);
assert_eq!(frames(&p, cid, &slow, Backend::Gpu, 3), 3);
let mut sw = p.clone();
sw.settings.gpu_acceleration = false;
assert_eq!(frames(&sw, cid, &slow, Backend::Auto, 3), 0);
}
/// Faster, tied and slightly noisier GPU costs stay on the GPU except during warm-up/probes.
#[test]
fn auto_backend_keeps_fast_accelerators() {
// As fast as the CPU (ties stay on the accelerator): every frame but the CPU warm-up and
// re-probes.
let (p, cid) = project();
let fast = TimedAccel::new(0, true);
let asked = frames(&p, cid, &fast, Backend::Auto, 40);
assert!(asked >= 36, "asked {asked} of 40");
for gpu_ms in [1.0, 5.0, 5.5] {
let fast = MockAccel::new(true);
let asked = measured_frames(&fast.pick, AutoKey::new(cid, 1.0, false), 5.0, gpu_ms, 40);
assert!(asked >= 36, "asked {asked} of 40 for GPU cost {gpu_ms}");
assert_eq!(frames(&p, cid, &fast, Backend::Auto, 1), 1);
}
}
/// A declining accelerator is retried only periodically while the CPU supplies frames.
#[test]
fn auto_backend_falls_back_when_the_accelerator_declines() {
let (p, cid) = project();
let declining = TimedAccel::new(0, false);
let declining = MockAccel::new(false);
let n = 2 * AutoPick::REPROBE as usize;
let asked = frames(&p, cid, &declining, Backend::Auto, n);
// Asked first, then only on the periodic re-probes.
assert!((1..=4).contains(&asked), "asked {asked} times in {n} frames");
}
/// Periodic probes detect a formerly slow GPU becoming faster, without mixing cache keys.
#[test]
fn auto_pick_follows_changing_costs() {
let pick = AutoPick::default();
+169
View File
@@ -0,0 +1,169 @@
//! Motion Tile's Output Width / Height on adjustment layers and on full-frame layers, with a
//! Transform after it that scales the tiled result into view (#1727, #1991).
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, render_frame};
const W: u32 = 200;
const H: u32 = 100;
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 2]) -> Layer {
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
l
}
/// A white frame with a red 20×20 square in its top-left corner.
fn picture(p: &mut Project, comp: &Comp) -> Vec<Layer> {
vec![solid(p, comp, [1.0, 0.0, 0.0], 20, 20, [10.0, 10.0]), solid(p, comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0])]
}
/// Motion Tile (Output `out` %) then Transform (Scale `scale` %), on `l`.
fn tile_then_scale(p: &mut Project, l: &mut Layer, out: f64, scale: f64) {
let size = [W as f64, H as f64];
for (id, vals) in [
("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out)), ("outputHeight", Value::Scalar(out))]),
("ec.distort.transform", vec![("scaleHeight", Value::Scalar(scale)), ("scaleWidth", Value::Scalar(scale))]),
] {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v;
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
}
fn comp() -> (Project, ItemId, Comp) {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
/// The picture under an adjustment layer carrying the two effects.
fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = comp();
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0]);
adj.switches.adjustment = true;
tile_then_scale(&mut p, &mut adj, out, scale);
let mut layers = vec![adj];
layers.extend(picture(&mut p, &comp));
p.comp_mut(cid).unwrap().layers = layers;
(p, cid)
}
/// The picture as a full-frame precomp layer carrying the two effects.
fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = comp();
let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let layers = picture(&mut p, &inner);
p.comp_mut(iid).unwrap().layers = layers;
let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None);
tile_then_scale(&mut p, &mut pre, out, scale);
p.comp_mut(cid).unwrap().layers.push(pre);
(p, cid)
}
fn is(img: &Image, x: i64, y: i64, want: [f32; 4], what: &str) {
let got = img.get(x, y);
assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "{what}: ({x}, {y}) is {got:?}, want {want:?}");
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const WHITE: [f32; 4] = [1.0; 4];
const CLEAR: [f32; 4] = [0.0; 4];
/// Output 300 % then Scale 50 % about the frame centre: comp pixel (x, y) shows the tiled frame
/// at (100 + 2(x − 100), 50 + 2(y − 50)), so the red square repeats every 100 × 50 pixels and
/// the frame is covered edge to edge, the tiles from beyond the frame included.
fn assert_tiled(img: &Image, what: &str) {
for (x, y) in [(55, 30), (155, 30), (55, 80), (155, 80)] {
is(img, x, y, RED, what);
}
for (x, y) in [(5, 5), (195, 5), (5, 95), (195, 95), (100, 50), (100, 5)] {
is(img, x, y, WHITE, what);
}
}
#[test]
fn adjustment_motion_tile_output_spreads_tiles_past_the_frame() {
let (p, cid) = adjustment_scene(300.0, 50.0);
assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "adjustment, Output 300 %");
}
#[test]
fn layer_motion_tile_output_spreads_tiles_past_the_frame() {
let (p, cid) = layer_scene(300.0, 50.0);
assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "full-frame layer, Output 300 %");
}
/// Output under 100 % crops: only the centre 50 % of the frame is left, the rest is transparent
/// (After Effects shows the comp background there).
#[test]
fn adjustment_motion_tile_output_under_100_crops_the_frame() {
let (p, cid) = adjustment_scene(50.0, 100.0);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
is(&img, 100, 50, WHITE, "inside the kept centre");
is(&img, 60, 30, WHITE, "inside the kept centre");
for (x, y) in [(5, 5), (10, 10), (195, 95), (45, 50), (100, 20)] {
is(&img, x, y, CLEAR, "outside the kept centre");
}
}
/// The After Effects 2026 frames in #1727: a 1920×1080 red solid under an adjustment layer with
/// Motion Tile (Output 910 × 340 %, then 90 × 340 %) and Transform (Scale 12 %, Position 1361, 540).
/// The tiled frame, 9.1 × 3.4 comps scaled to 12 %, is a 2097 × 441 band centred on (1361, 540):
/// x 313–1920 (cut by the frame), y 320–760; at 90 % a 207-pixel column, x 1257–1465.
#[test]
fn adjustment_motion_tile_matches_the_after_effects_frames() {
for (out_w, x0, x1) in [(910.0, 313.0, 1920.0), (90.0, 1257.0, 1465.0)] {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080, [960.0, 540.0]);
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 1920, 1080, [960.0, 540.0]);
adj.switches.adjustment = true;
for (id, vals) in [
("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out_w)), ("outputHeight", Value::Scalar(340.0))]),
(
"ec.distort.transform",
vec![("scaleHeight", Value::Scalar(12.0)), ("scaleWidth", Value::Scalar(12.0)), ("position", Value::Vec2([1361.0, 540.0]))],
),
] {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v;
}
adj.props.sub_mut("effects").unwrap().children.push(g.into());
}
p.comp_mut(cid).unwrap().layers = vec![adj, red];
// Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4).
let img = render_frame(&p, cid, Tick::ZERO, 0.25);
let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64);
let what = format!("Output {out_w} %");
for (x, y) in [(x0 + 8.0, 540.0), (x1 - 8.0, 540.0), (1361.0, 328.0), (1361.0, 752.0)] {
assert_eq!(at(x, y), RED, "{what}: ({x}, {y}) is red");
}
for (x, y) in [(x0 - 8.0, 540.0), (1361.0, 312.0), (1361.0, 768.0), (100.0, 100.0)] {
assert_eq!(at(x, y), CLEAR, "{what}: ({x}, {y}) is empty");
}
if x1 < 1920.0 {
assert_eq!(at(x1 + 8.0, 540.0), CLEAR, "{what}: right of the column");
}
}
}
+102 -2
View File
@@ -8,6 +8,9 @@
//! (already sorted back to front) are then shaded per fragment and blended over, depth-tested
//! against the opaque surfaces.
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use rayon::prelude::*;
use super::scene::{Scene, ShadowMap, Vertex};
@@ -422,6 +425,81 @@ fn shadow_raster(s: &Scene, m: &ShadowMap, casters: &[usize]) -> Vec<f32> {
d
}
/// Hard cap on the shadow depth texels one render allocates (f32 depth texels).
/// Quality map sizes derive from the raster resolution, and both the resolution and
/// the light count come from the project, so without a scene-wide budget an
/// arbitrarily large comp with arbitrarily many casting lights (point lights cost
/// six faces) could balloon the allocation. 26 M texels (≈ 104 MB) holds one point
/// light's full 2048-texel cube (6 × 2048² ≈ 25.2 M) and stays under WebGPU's default
/// 128 MiB storage-binding limit; adapters with less fall back to the CPU raster.
pub(crate) const MAX_SHADOW_TEXELS: u64 = 26_000_000;
/// Long side of a quality shadow map: one map texel per output pixel, to a power of
/// two, clamped to 512..4096 (raster sizes are user data and may be absurd). Draft
/// (interactive fast-preview) renders keep the flat 512 budget, unchanged.
pub(crate) fn shadow_map_size(width: u32, height: u32, draft: bool) -> u32 {
if draft {
return 512;
}
let side = width.max(height).clamp(512, 4096);
let mut size = 512u32;
while size < side {
size *= 2;
}
size
}
/// Per-face size of a point light's cube map: the full map size, since each face spans
/// 90° and anything smaller is coarser than a spot light's map in the same comp (a
/// point light low over a floor shows exactly that as stair-steps), capped at 2048 so
/// one cube fits [`MAX_SHADOW_TEXELS`]. Draft keeps half the map size as before.
pub(crate) fn shadow_point_face_size(size: u32, draft: bool) -> u32 {
if draft { (size / 2).max(1) } else { size.min(2048) }
}
/// Per-light map sizes (`None` = this light gets no shadow map). `kinds`: 0 = no
/// shadows (costs nothing), 1 = single map (spot/parallel), 2 = point cube (budgeted
/// as its six faces). Lights start at their full size; while the scene exceeds
/// [`MAX_SHADOW_TEXELS`] the largest map above its floor is halved (power-of-two
/// steps, the earliest light first on ties), and once every light is at its floor the
/// last lights lose their shadows entirely. Deterministic, and O(n log n) in the
/// light count: each light is halved at most a handful of times and dropped once.
pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) -> Vec<Option<u32>> {
let base = shadow_map_size(width, height, draft);
let faces = |k: u32| -> u64 { if k == 2 { 6 } else { 1 } };
let floor = |k: u32| -> u32 { if k == 2 { 128 } else { 512 } };
let mut sizes: Vec<u32> = kinds
.iter()
.map(|&k| match k {
0 => 0,
2 => shadow_point_face_size(base, draft),
_ => base,
})
.collect();
let cost = |z: u32, k: u32| -> u64 { (z as u64) * (z as u64) * faces(k) };
let mut total: u64 = sizes.iter().zip(kinds).map(|(&z, &k)| cost(z, k)).sum();
// Largest size first, then the lowest index.
let mut heap: BinaryHeap<(u32, Reverse<usize>)> =
sizes.iter().zip(kinds).enumerate().filter(|(_, (z, k))| **z > floor(**k)).map(|(i, (&z, _))| (z, Reverse(i))).collect();
while total > MAX_SHADOW_TEXELS {
let Some((z, Reverse(i))) = heap.pop() else { break };
let k = kinds[i];
total -= cost(z, k) - cost(z / 2, k);
sizes[i] = z / 2;
if z / 2 > floor(k) {
heap.push((z / 2, Reverse(i)));
}
}
for i in (0..sizes.len()).rev() {
if total <= MAX_SHADOW_TEXELS {
break;
}
total -= cost(sizes[i], kinds[i]);
sizes[i] = 0;
}
sizes.into_iter().map(|z| (z > 0).then_some(z)).collect()
}
/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`).
pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
let casters: Vec<usize> = (0..s.indices.len() / 3)
@@ -443,15 +521,37 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
}
let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) * 0.5);
let radius = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5 + 1.0;
let size = if draft { 512 } else { 1024 };
// Map sizes come from the raster (a quality map gives ~one texel per output
// pixel); the per-light budget keeps the scene allocation bounded because
// comp size and light count are user input (point lights cost six faces).
let kinds: Vec<u32> = s
.lights
.iter()
.map(|l| {
if l.shadow != 0 {
0
} else if l.kind > 1 {
2
} else {
1
}
})
.collect();
let sizes = shadow_sizes(s.width, s.height, draft, &kinds);
let mut maps: Vec<ShadowMap> = vec![];
for li in 0..s.lights.len() {
let Some(size) = sizes.get(li).copied().flatten() else {
s.lights[li].shadow = -1;
continue;
};
if s.lights[li].shadow != 0 {
s.lights[li].shadow = -1;
continue;
}
let l = s.lights[li];
let first = maps.len() as i32;
// Penumbra width in output pixels (the shader turns it into a tap reach at
// the receiver); the sampling bias is deliberately independent of it.
let soft = 1.0 + (l.shadow_diffusion * 0.5).min(24.0);
let pos = l.pos.map(|v| v as f64);
let dir = l.dir.map(|v| v as f64);
@@ -468,7 +568,7 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
maps.push(ShadowMap { view: basis(pos, dir, hint), focal, ortho: false, size, offset: 0, bias: 1.5, radius: soft });
}
_ => {
let fs = size / 2;
let fs = size;
for (f, d) in [
([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
+2 -1
View File
@@ -157,7 +157,8 @@ pub struct ShadowMap {
pub offset: u32,
/// Depth bias in texels (scaled by the texel footprint at the receiver).
pub bias: f32,
/// PCF spread (texels).
/// Penumbra width (output pixels) requested by Shadow Diffusion; the shader
/// converts it to a tap reach via the receiver's texel footprint.
pub radius: f32,
}
+115 -24
View File
@@ -206,36 +206,125 @@ pub fn shadow_coord(m: &ShadowMap, p: [f32; 3]) -> Option<(f32, f32, f32)> {
}
}
/// Fraction of light reaching `p` through shadow map `m` (1 = lit), PCF over a 5×5 grid
/// spread over `radius` texels.
pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], n_l: f32) -> f32 {
/// Integer tap jitter hash: pure u32 bit math so the WGSL backend computes
/// bit-identical jitter from the same inputs (a float hash would diverge between
/// backends and break CPU/GPU parity).
fn tap_hash(x: i32, y: i32) -> u32 {
let mut h = (x as u32).wrapping_mul(0x9E37_79B9) ^ (y as u32).wrapping_mul(0x85EB_CA6B);
h ^= h >> 16;
h = h.wrapping_mul(0x2545_F491);
h ^= h >> 15;
h
}
/// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps
/// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment
/// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest).
const PCF_MAX_TAPS: i32 = 10;
/// Steepest receiver-plane slope the PCF extrapolates, in texel footprints of depth per
/// texel (about 89°). Steeper planes are edge-on to the light, where their depth runs
/// away; they are clamped to this slope.
const PLANE_SLOPE_MAX: f32 = 64.0;
/// Fraction of light reaching `p` through shadow map `m` (1 = lit). `ng` is the
/// receiver's geometric (not normal-mapped) normal facing the viewer.
///
/// PCF over an N×N tap grid (5 ≤ N ≤ [`PCF_MAX_TAPS`]) spread over a reach derived
/// from `radius` (the wanted penumbra in output pixels) divided by the receiver's
/// texel footprint, each tap jittered by an integer hash of the receiver's
/// sub-texel cell and the tap index, and each tap comparing against the four
/// nearest texel centres with bilinear weights. The jitter detaches the tap grid
/// from the texel axes (axis-aligned binary taps are what stair-step a straight
/// shadow edge), and keying it below texel size keeps the dither finer than an
/// output pixel even where one texel covers several. The bilinear compare makes
/// each tap's contribution vary smoothly inside a texel instead of snapping.
///
/// Every texel is compared against the depth the receiver's own plane has at that
/// texel's centre, not the receiver point's depth: a wide kernel on a surface tilted
/// towards the light would otherwise see its own nearer depth and shadow itself.
/// With that, the bias stays at texel scale; a diffusion-scaled bias lifts shadows
/// off their casters.
pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 {
let Some((u, v, z)) = shadow_coord(m, p) else { return 1.0 };
let size = m.size as i64;
// Slope-scaled bias (world units are pixels).
// Texel footprint (world units) × PCF reach, steeper for grazing light.
// Slope-scaled bias at texel scale (world units are pixels), steeper for
// grazing light: it absorbs depth-raster error and the gap between the shading
// normal's plane and the faceted surface the map rasterised.
let texel = if m.ortho { 1.0 / m.focal } else { z.abs() / m.focal };
let bias = m.bias * texel * (1.0 + m.radius) * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0)));
let step = m.radius / 2.0;
let bias = m.bias * texel * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0)));
let at = |xi: i64, yi: i64| -> f32 {
if xi < 0 || yi < 0 || xi >= size || yi >= size {
return f32::INFINITY; // outside the map: the light was never blocked there (WGSL mirrors this with a large finite depth)
}
texels.get(m.offset as usize + (yi * size + xi) as usize).copied().unwrap_or(f32::INFINITY)
};
// The receiver plane in light space as the slope of its depth across the map, in
// texel footprints per texel (`tx`, `ty`; depth grows along −t), clamped where the
// plane grazes the light. `den0` is the plane normal against the light ray through
// the receiver (the depth axis for an orthographic map), negative while it faces it.
let w = &m.view;
let nv = [0, 1, 2].map(|r| w[r][0] * ng[0] + w[r][1] * ng[1] + w[r][2] * ng[2]);
let c = m.size as f32 * 0.5;
let den0 = if m.ortho { nv[2] } else { nv[0] * ((u - c) / m.focal) + nv[1] * ((v - c) / m.focal) + nv[2] };
let (tx, ty) = if den0 < -1e-6 {
((nv[0] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), (nv[1] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX))
} else {
(0.0, 0.0)
};
// 1 where texel (xi, yi) does not occlude the receiver plane at the texel's centre.
// Perspective depth is not linear across the map but its inverse is, so that
// compare runs on 1/depth (inv · (texel + bias) ≥ 1) without a division; a plane
// whose inverse depth reaches 0 has left the light's side and cannot be occluded.
let (iz, iq) = (1.0 / z, 1.0 / (m.focal * z));
let open = |xi: i64, yi: i64| -> f32 {
let s = at(xi, yi) + bias;
let g = tx * (xi as f32 + 0.5 - u) + ty * (yi as f32 + 0.5 - v);
let lit = if m.ortho {
z - g * texel <= s
} else {
let inv = iz + g * iq;
inv <= 0.0 || inv * s >= 1.0
};
if lit { 1.0 } else { 0.0 }
};
// `radius` is the wanted penumbra in output pixels; converting it through the
// receiver's texel footprint keeps the penumbra width in output pixels at any
// map resolution (and lets perspective penumbra grow with distance from the light).
// At least two texels: a hard edge then ramps across two texels, which hides the
// texel steps of the binary depth compare where one texel spans several output
// pixels (receivers grazing the light); a one-texel ramp leaves a sawtooth.
let mut reach = (m.radius / texel).clamp(2.0, 256.0);
if !reach.is_finite() {
reach = 1.0;
}
let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS);
let step = reach / (n - 1) as f32;
let half = (n - 1) as f32 * 0.5;
let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32);
let mut lit = 0.0;
for j in -2..=2 {
for i in -2..=2 {
let x = (u + i as f32 * step).floor() as i64;
let y = (v + j as f32 * step).floor() as i64;
if x < 0 || y < 0 || x >= size || y >= size {
lit += 1.0;
continue;
}
let d = texels[m.offset as usize + (y * size + x) as usize];
if z - bias <= d {
lit += 1.0;
}
for j in 0..n {
for i in 0..n {
let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919)));
let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;
let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;
let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step);
// Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the
// four is compared against the receiver plane at that centre.
let (tu, tv) = (su - 0.5, sv - 0.5);
let (fx, fy) = (tu - tu.floor(), tv - tv.floor());
let (x0, y0) = (tu.floor() as i64, tv.floor() as i64);
let (h00, h10, h01, h11) = (open(x0, y0), open(x0 + 1, y0), open(x0, y0 + 1), open(x0 + 1, y0 + 1));
let a = h00 + (h10 - h00) * fx;
let b = h01 + (h11 - h01) * fx;
lit += a + (b - a) * fy;
}
}
lit / 25.0
lit / (n * n) as f32
}
/// The visibility of light `li` at `p` (1 = unshadowed), darkened by Shadow Darkness.
pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 {
pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 {
if l.shadow < 0 {
return 1.0;
}
@@ -258,7 +347,7 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 {
s.shadows.get(first)
};
let Some(m) = m else { return 1.0 };
let f = shadow_factor(m, &s.shadow_texels, p, n_l);
let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l);
1.0 - l.shadow_darkness * (1.0 - f)
}
@@ -367,6 +456,8 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
if !f.front {
n = scale(n, -1.0);
}
// Shadow lookups follow the geometric surface, not the normal map.
let ng = n;
if let Some(t) = tex(m.tex_normal) {
let tn = [(t[0] * 2.0 - 1.0) * m.normal_scale, (t[1] * 2.0 - 1.0) * m.normal_scale, t[2] * 2.0 - 1.0];
let tg = [f.tangent[0], f.tangent[1], f.tangent[2]];
@@ -392,7 +483,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
if n_l <= 0.0 {
continue;
}
let sh = if m.receives_shadows { light_shadow(s, l, f.pos, n_l) } else { 1.0 };
let sh = if m.receives_shadows { light_shadow(s, l, f.pos, ng, n_l) } else { 1.0 };
let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l);
for c in 0..3 {
out[c] += b[c] * l.color[c] * k * sh;
@@ -433,7 +524,7 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] {
if n_l <= 0.0 {
continue;
}
amount = amount.max(1.0 - light_shadow(s, l, f.pos, n_l));
amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l));
}
[0.0, 0.0, 0.0, alpha * amount]
}
+366
View File
@@ -681,3 +681,369 @@ fn stroke_regions_are_rings() {
assert_eq!(outlines.len(), 1);
assert_eq!(outlines[0].holes.len(), 1);
}
// ---------------------------------------------------------------- shadow quality
use super::raster::{MAX_SHADOW_TEXELS, shadow_map_size, shadow_point_face_size, shadow_sizes};
use super::scene::ShadowMap;
/// Geometric normal of a receiver squarely facing a `flat_map` light (which looks along +z).
const FACING: [f32; 3] = [0.0, 0.0, -1.0];
/// Identity-view orthographic map: map coords are world x, y × `focal` offset by
/// `size/2` (`focal` texels per world unit).
fn flat_map(size: u32, focal: f32, radius: f32) -> ShadowMap {
ShadowMap { view: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], focal, ortho: true, size, offset: 0, bias: 1.5, radius }
}
/// Wall: texel columns ≥ a quarter of the map at depth 10, the rest unoccluded (1000).
/// Its edge sits at world x = −size / (4·focal).
fn wall_map(size: u32, focal: f32, radius: f32) -> (ShadowMap, Vec<f32>) {
let texels: Vec<f32> = (0..size * size).map(|i| if i % size >= size / 4 { 10.0 } else { 1000.0 }).collect();
(flat_map(size, focal, radius), texels)
}
/// Luma of a render of `cid` with its top layer (a light) set to cast shadows or not,
/// at the given Shadow Diffusion.
fn render_light_luma(p: &mut Project, cid: ItemId, casting: bool, diffusion: f64) -> Vec<f32> {
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(casting));
set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(diffusion));
render(p, cid).data.iter().map(|q| (q[0] + q[1] + q[2]) / 3.0).collect()
}
#[test]
fn shadow_map_size_tracks_raster_resolution() {
assert_eq!(shadow_map_size(640, 360, false), 1024);
assert_eq!(shadow_map_size(1920, 1080, false), 2048);
assert_eq!(shadow_map_size(3840, 2160, false), 4096);
// Clamped to the pow2 range: small or absurd rasters stay inside 512..4096.
assert_eq!(shadow_map_size(64, 64, false), 512);
assert_eq!(shadow_map_size(0, 0, false), 512);
assert_eq!(shadow_map_size(u32::MAX, u32::MAX, false), 4096);
assert_eq!(shadow_map_size(7, 100000, false), 4096);
// The fast-preview (draft) budget stays at 512 regardless, so interactivity is
// untouched by the quality path's new sizing.
assert_eq!(shadow_map_size(3840, 2160, true), 512);
assert_eq!(shadow_map_size(0, 0, true), 512);
}
#[test]
fn shadow_point_faces_are_capped() {
// Cube faces take the full map size up to 2048 (one 2048 cube ≈ 25 M f32 texels).
assert_eq!(shadow_point_face_size(4096, false), 2048);
assert_eq!(shadow_point_face_size(2048, false), 2048);
assert_eq!(shadow_point_face_size(1024, false), 1024);
assert_eq!(shadow_point_face_size(512, false), 512);
// Draft keeps the historical half-size face.
assert_eq!(shadow_point_face_size(512, true), 256);
}
fn budget_texels(sizes: &[Option<u32>], kinds: &[u32]) -> u64 {
sizes.iter().zip(kinds).map(|(s, &k)| s.map(|z| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).unwrap_or(0)).sum()
}
#[test]
fn shadow_sizes_stay_within_the_scene_budget() {
// Within budget a light gets its full size: one 4K point cube keeps 2048 faces.
assert_eq!(shadow_sizes(4096, 2160, false, &[2]), vec![Some(2048)]);
// Several cubes overflow: deterministically halved (largest first, earliest light
// on ties) until inside: three or four settle at 1024, five push two to 512.
assert_eq!(shadow_sizes(4096, 2160, false, &[2; 3]), vec![Some(1024); 3]);
assert_eq!(shadow_sizes(4096, 2160, false, &[2; 4]), vec![Some(1024); 4]);
let sizes = shadow_sizes(4096, 2160, false, &[2; 5]);
assert_eq!(sizes, vec![Some(512), Some(512), Some(1024), Some(1024), Some(1024)]);
assert!(budget_texels(&sizes, &[2; 5]) <= MAX_SHADOW_TEXELS);
// A hostile light count can never break the cap; sizes only shrink or drop to None.
let many = vec![2u32; 1000];
let sizes = shadow_sizes(3840, 2160, false, &many);
assert_eq!(sizes.len(), 1000);
assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS);
assert!(sizes.iter().flatten().all(|&z| (128..=2048).contains(&z)));
// Deterministic: same inputs, same plan.
assert_eq!(sizes, shadow_sizes(3840, 2160, false, &many));
// Non-point lights and a modest scene keep full quality sizes.
assert_eq!(shadow_sizes(1920, 1080, false, &[1; 2]), vec![Some(2048), Some(2048)]);
// A light that casts nothing costs nothing: it stays None and leaves its
// full-size budget to the lights that do cast.
assert_eq!(shadow_sizes(640, 360, false, &[0, 1]), vec![None, Some(1024)]);
assert!(shadow_sizes(640, 360, false, &[0; 500]).iter().all(Option::is_none));
}
#[test]
fn diffusion_does_not_lift_a_deep_shadow() {
// Shadow Diffusion may widen the penumbra but must not scale the depth bias: a
// point deep inside a shadow stays fully shadowed at any diffusion.
let (m, texels) = wall_map(64, 1.0, 1.0);
let deep = [0.0f32, 0.0, 13.5]; // u = 32: ±16 texels of margin around the edge at 16; z − bias 12 > 10
let hard = shade::shadow_factor(&m, &texels, deep, FACING, 1.0);
assert!(hard < 0.01, "deep shadow at radius 1: {hard}");
let wide = shade::shadow_factor(&flat_map(64, 1.0, 25.0), &texels, deep, FACING, 1.0);
assert!(wide < 0.01, "deep shadow at radius 25 (bias must not grow with radius): {wide}");
}
#[test]
fn pcf_varies_within_a_texel() {
// Nearest-texel PCF snaps the edge to texel boundaries: two receivers 1/20 texel
// apart get bit-identical factors, which is what stair-steps straight edges.
// The filtering must produce coverage that varies within a single texel.
let (m, texels) = wall_map(64, 1.0, 2.0);
let mut pairs = 0;
let mut varying = 0;
let mut u = -17.0f32;
while u < -15.3 {
let a = shade::shadow_factor(&m, &texels, [u, 0.0, 13.5], FACING, 1.0);
let b = shade::shadow_factor(&m, &texels, [u + 0.05, 0.0, 13.5], FACING, 1.0);
pairs += 1;
if (a - b).abs() > 1e-6 {
varying += 1;
}
assert!((0.0..=1.0).contains(&a) && (0.0..=1.0).contains(&b), "{a} {b}");
u += 0.05;
}
assert!(pairs >= 30);
assert_eq!(varying, pairs, "coverage varies inside every texel along the edge");
}
#[test]
fn wide_diffusion_keeps_shadow_depth_and_contact() {
// Scene: camera-facing floor, sphere casting, point light behind the camera and
// off to the upper right so most of the shadow lands beside the sphere, not
// behind it. Raising Shadow Diffusion must neither wash the shadow out (a
// diffusion-scaled bias leaks light into it) nor move where it touches.
let (mut p, cid) = project(200, 150);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(1));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
set(l, "transform/position", Value::Vec3([180.0, 20.0, -300.0]));
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let noshadow = render_light_luma(&mut p, cid, false, 0.0);
let d0 = render_light_luma(&mut p, cid, true, 0.0);
let d40 = render_light_luma(&mut p, cid, true, 40.0);
// Per-column darkening vs the shadowless render: depth (max darkening) and the
// deepest dark row (contact edge). Diffusion may blur the edge but must keep the
// shadow dark (no bias leak) and keep it where it touches (no lift).
let dep = |img: &[f32], x: usize| -> f32 { (20..140).map(|y| noshadow[y * 200 + x] - img[y * 200 + x]).fold(0.0f32, f32::max) };
let cols: Vec<usize> = (30..170).filter(|&x| dep(&d0, x) > 0.1).collect();
assert!(cols.len() > 30, "shadow covers many columns: {}", cols.len());
// Core depth: the 90th-percentile darkening over the d0 shadow's pixels. A blur
// only softens the rim and leaves the core dark; a bias leak lightens all of it.
let shadow_px: Vec<usize> = (20..140).flat_map(|y| (30..170).map(move |x| y * 200 + x)).filter(|&i| noshadow[i] - d0[i] > 0.1).collect();
let core = |img: &[f32]| -> f32 {
let mut v: Vec<f32> = shadow_px.iter().map(|&i| noshadow[i] - img[i]).collect();
v.sort_by(f32::total_cmp);
v[v.len() * 9 / 10]
};
let (core0, core40) = (core(&d0), core(&d40));
// Width of the shadow across its centre row: a diffusion-scaled bias shrinks the
// shadow (light leaks under its edges), while a correct filter keeps the extent
// and only softens the boundary (threshold crossings move at most with the
// penumbra, not with the bias).
let width = |img: &[f32]| -> f32 {
(30..130)
.map(|y| {
let dark: Vec<bool> = (0..200).map(|x| noshadow[y * 200 + x] - img[y * 200 + x] > 0.06).collect();
match (dark.iter().position(|&d| d), dark.iter().rposition(|&d| d)) {
(Some(l), Some(r)) if r > l => (r - l) as f32,
_ => 0.0,
}
})
.fold(0.0f32, f32::max)
};
let (w0, w40) = (width(&d0), width(&d40));
let wratio = w40 / w0;
assert!(w0 > 20.0, "shadow is wide enough to judge: {w0}");
assert!(core40 >= 0.9 * core0, "diffusion 40 keeps the shadow core dark: {core40:.4} vs {core0:.4}");
assert!(wratio >= 0.8, "diffusion 40 does not shrink the shadow: width {w40} vs {w0}");
}
#[test]
fn penumbra_dithers_along_a_straight_edge() {
// Receivers at the same distance from a straight edge, one texel apart along it.
// A fixed tap grid gives each the same coverage, so the penumbra repeats texel by
// texel (stair-steps and bands); the jitter, keyed below texel size, must vary it.
let (m, texels) = wall_map(64, 1.0, 6.0);
let distinct = |vs: &[f32]| -> usize {
let mut bits: Vec<u32> = vs.iter().map(|v| v.to_bits()).collect();
bits.sort_unstable();
bits.dedup();
bits.len()
};
let along: Vec<f32> = (0..32).map(|k| shade::shadow_factor(&m, &texels, [-16.0, -16.0 + k as f32, 13.5], FACING, 1.0)).collect();
assert!(distinct(&along) >= 16, "coverage along the edge takes {} distinct values", distinct(&along));
// And finer than a texel: where one texel spans several output pixels (a grazing
// floor), dither keyed per texel would show as texel-sized blocks of grain.
let within: Vec<f32> = (0..8).map(|k| shade::shadow_factor(&m, &texels, [-16.0, 0.0625 + k as f32 * 0.125, 13.5], FACING, 1.0)).collect();
assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within));
}
#[test]
fn penumbra_width_is_independent_of_map_resolution() {
// One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and
// a 256-texel map at 4. Shadow Diffusion sets the penumbra in output pixels (world
// units here), so a finer map must not narrow it.
let width = |size: u32, focal: f32| -> f32 {
let (m, texels) = wall_map(size, focal, 8.0);
let xs: Vec<f32> = (0..400).map(|k| -30.0 + k as f32 * 0.07).collect();
let f: Vec<f32> = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, 0.0, 13.5], FACING, 1.0)).collect();
let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]);
first_below(0.1) - first_below(0.9)
};
let (w64, w256) = (width(64, 1.0), width(256, 4.0));
assert!(w64 > 4.0 && w64 < 12.0, "penumbra spans about the requested 8 units: {w64}");
assert!((w256 / w64 - 1.0).abs() < 0.25, "same penumbra at any map resolution: {w64} vs {w256}");
}
#[test]
fn tilted_receivers_do_not_shadow_themselves() {
// A receiver plane z = 100 + 4x (76° from the light, n·l ≈ 0.24), each texel holding
// its depth half a texel off-centre the way a rasteriser can. Wide taps must compare
// against the plane rather than the receiver point, and the slope-scaled bias must
// absorb the half-texel error, at every diffusion.
let (size, g) = (128u32, 4.0f32);
let c = size as f32 * 0.5;
let texels: Vec<f32> = (0..size * size).map(|i| 100.0 + g * ((i % size) as f32 - c)).collect();
let len = (1.0 + g * g).sqrt();
let (ng, n_l) = ([g / len, 0.0, -1.0 / len], 1.0 / len);
for radius in [1.0f32, 6.0, 25.0] {
let m = flat_map(size, 1.0, radius);
for k in 0..40 {
let x = -20.0 + k as f32 * 0.97;
let f = shade::shadow_factor(&m, &texels, [x, 3.3, 100.0 + g * x], ng, n_l);
assert!(f > 0.99, "radius {radius}, x {x}: lit tilted receiver shadowed itself ({f})");
}
}
}
#[test]
fn lit_casters_do_not_shadow_themselves() {
// A sphere and a turned cube that cast shadows, lit by each light kind: the lit
// surface must render as bright as with shadows off at any Shadow Diffusion (a
// wide kernel on a surface tilted to the light must not see its own depth).
// 640×360: faces then span enough texels that near-grazing ones exercise the
// receiver plane's slope limit.
for (prim, rot_y) in [(PrimitiveKind::Sphere, 0.0), (PrimitiveKind::Cube, 35.0)] {
for kind in [LightKind::Point, LightKind::Spot, LightKind::Parallel] {
let (mut p, cid) = project(640, 360);
add(&mut p, cid, LayerSource::Primitive { kind: prim }, |l| {
set(l, "transform/position", Value::Vec3([320.0, 180.0, 100.0]));
if prim == PrimitiveKind::Sphere {
set(l, "geometryOptions/radius", Value::Scalar(120.0));
} else {
for k in ["width", "height", "depth"] {
set(l, &format!("geometryOptions/{k}"), Value::Scalar(216.0));
}
set(l, "transform/rotationX", Value::Scalar(-25.0));
}
set(l, "transform/rotationY", Value::Scalar(rot_y));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
set(l, "materialOptions/castsShadows", Value::Enum(1));
});
add(&mut p, cid, LayerSource::Light { kind }, |l| {
set(l, "transform/position", Value::Vec3([-280.0, -108.0, -600.0]));
if let Some(poi) = l.props.prop_mut("transform/pointOfInterest") {
poi.value = Value::Vec3([320.0, 180.0, 100.0]);
}
});
let noshadow = render_light_luma(&mut p, cid, false, 0.0);
let lit: Vec<usize> = (0..noshadow.len()).filter(|&i| noshadow[i] > 0.15).collect();
assert!(lit.len() > 10_000, "{prim:?} {kind:?}: lit area {}", lit.len());
for diffusion in [0.0, 20.0, 48.0] {
let img = render_light_luma(&mut p, cid, true, diffusion);
let dark = lit.iter().filter(|&&i| img[i] < 0.9 * noshadow[i]).count() as f32 / lit.len() as f32;
assert!(dark <= 0.02, "{prim:?} {kind:?} diffusion {diffusion}: {:.1} % of the lit surface self-shadowed", dark * 100.0);
}
}
}
}
#[test]
fn shadow_sizes_match_the_halve_largest_rule_and_scale_to_many_lights() {
// The specification `shadow_sizes` implements, step by step: halve the largest map
// above its floor (earliest light on ties) until the scene fits, then drop maps
// from the last light.
let spec = |w: u32, h: u32, draft: bool, kinds: &[u32]| -> Vec<Option<u32>> {
let base = shadow_map_size(w, h, draft);
let mut sizes: Vec<u32> = kinds
.iter()
.map(|&k| {
if k == 0 {
0
} else if k == 2 {
shadow_point_face_size(base, draft)
} else {
base
}
})
.collect();
let total = |s: &[u32]| -> u64 { s.iter().zip(kinds).map(|(&z, &k)| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).sum() };
while total(&sizes) > MAX_SHADOW_TEXELS {
let pick = (0..sizes.len()).filter(|&i| sizes[i] > if kinds[i] == 2 { 128 } else { 512 }).max_by_key(|&i| (sizes[i], std::cmp::Reverse(i)));
match pick {
Some(i) => sizes[i] /= 2,
None => match sizes.iter().rposition(|&z| z > 0) {
Some(i) => sizes[i] = 0,
None => break,
},
}
}
sizes.into_iter().map(|z| (z > 0).then_some(z)).collect()
};
let mut seed = 0x2545_F491u32;
for case in 0..300 {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let n = (seed >> 8) as usize % 40;
let kinds: Vec<u32> = (0..n)
.map(|_| {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(seed >> 16) % 3
})
.collect();
let (w, h, draft) = ([640, 1920, 3840, 8000][case % 4], [360, 1080, 2160, 100][case % 4], case % 7 == 0);
assert_eq!(shadow_sizes(w, h, draft, &kinds), spec(w, h, draft, &kinds), "case {case}: {kinds:?}");
}
// A hostile light count stays fast (n log n) and inside the budget.
let many = vec![2u32; 100_000];
let sizes = shadow_sizes(3840, 2160, false, &many);
assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS);
}
#[test]
fn wide_penumbrae_are_smooth() {
// Coverage at a fixed distance across a wide penumbra, sampled along a straight
// edge, should be one smooth value plus fine dither. A 5×5 grid stretched over a
// 20-texel reach leaves gaps the jitter turns into coarse grain (σ ≈ 0.04, ten
// 8-bit levels); the grid grows with the reach to keep it below 0.02.
let (m, texels) = wall_map(256, 1.0, 20.0);
for off in [-5.0f32, 0.0, 5.0] {
let vs: Vec<f32> = (0..200).map(|k| shade::shadow_factor(&m, &texels, [-64.0 + off, -100.0 + k as f32, 13.5], FACING, 1.0)).collect();
let mean = vs.iter().sum::<f32>() / vs.len() as f32;
let sd = (vs.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / vs.len() as f32).sqrt();
assert!(sd < 0.02, "offset {off}: penumbra grain σ = {sd:.4}");
}
}
#[test]
fn hard_edges_ramp_over_two_texels() {
// At Shadow Diffusion 0 the edge still ramps across about two texels (10–90 %):
// where one texel spans several output pixels (a floor grazing a point light) a
// one-texel ramp leaves the binary compare's texel steps visible as a sawtooth.
let (m, texels) = wall_map(64, 1.0, 1.0);
for v0 in [0.0f32, 0.3, 7.6] {
let xs: Vec<f32> = (0..400).map(|k| -20.0 + k as f32 * 0.02).collect();
let f: Vec<f32> = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, v0, 13.5], FACING, 1.0)).collect();
let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]);
let w = first_below(0.1) - first_below(0.9);
assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels");
}
}
+1
View File
@@ -85,6 +85,7 @@ fn project(s: &Session) -> J {
"workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(),
"workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 },
"timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames,
"displayStartFrame": p.settings.frame_start,
"renderQueue": p.render_queue.len(),
"revision": s.revision,
})
+10
View File
@@ -98,6 +98,8 @@ var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "CO
ImportAsType.COMPOSITION_CROPPED_LAYERS = ImportAsType.COMP_CROPPED_LAYERS;
ImportAsType.COMPOSITION = ImportAsType.COMP;
var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012);
// After Effects' values (FC_START_0 = 2612), as in docsforadobe/Types-for-Adobe.
var FramesCountType = __enum("FramesCountType", ["FC_START_0", "FC_START_1", "FC_TIMECODE_CONVERSION"], 2612);
var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212);
var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0);
@@ -1894,6 +1896,14 @@ Project.prototype = {
set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); },
get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; },
set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); },
get framesCountType() { return this.__info().displayStartFrame === 1 ? FramesCountType.FC_START_1 : FramesCountType.FC_START_0; },
set framesCountType(v) {
if (v === FramesCountType.FC_TIMECODE_CONVERSION) throw __err("framesCountType: Timecode Conversion is not supported, use FC_START_0 or FC_START_1");
if (v !== FramesCountType.FC_START_0 && v !== FramesCountType.FC_START_1) throw __err("Bad framesCountType value " + v);
__call("file.projectSettings", { frameStart: v === FramesCountType.FC_START_1 ? 1 : 0 });
},
get displayStartFrame() { return this.__info().displayStartFrame; },
set displayStartFrame(v) { __call("file.projectSettings", { frameStart: __num(v) }); },
get expressionEngine() { return "javascript-1.0"; },
set expressionEngine(v) {},
item: function (i) {
+20
View File
@@ -611,6 +611,26 @@ fn engine_entry_points() {
let _ = std::fs::remove_dir_all(&dir);
}
/// `app.project.framesCountType` / `displayStartFrame`: Project Settings ▸ Frame Count (#1807).
#[test]
fn frames_count_type_sets_the_project_frame_count() {
let mut s = session();
let o = ok(
&mut s,
"var p = app.project; var a = [p.framesCountType === FramesCountType.FC_START_0, p.displayStartFrame]; p.framesCountType = FramesCountType.FC_START_1; a.concat([p.framesCountType === FramesCountType.FC_START_1, p.displayStartFrame]).join('|')",
);
assert_eq!(o.result, json!("true|0|true|1"));
// After Effects' enum values, so scripts that store the number keep working.
let o = ok(&mut s, "[FramesCountType.FC_START_0, FramesCountType.FC_START_1, FramesCountType.FC_TIMECODE_CONVERSION].join('|')");
assert_eq!(o.result, json!("2612|2613|2614"));
assert_eq!(s.project.settings.frame_start, 1);
ok(&mut s, "app.project.displayStartFrame = 0");
assert_eq!(s.project.settings.frame_start, 0);
let o = run_code(&mut s, "app.project.framesCountType = FramesCountType.FC_TIMECODE_CONVERSION", "test.jsx");
assert!(o.error.is_some_and(|e| e.message.contains("Timecode Conversion")));
assert_eq!(s.project.settings.frame_start, 0);
}
#[test]
fn console_keeps_variables() {
let mut s = session();
+150 -31
View File
@@ -392,7 +392,17 @@ enum PaintRef {
Current,
}
/// A CSS rule: selector (type, class, id; `*`) with its specificity and declarations.
/// The most selectors (each entry of a comma list counts) the `<style>` sheets of a document may
/// hold (#2140).
const MAX_CSS_SELECTORS: usize = 20_000;
/// The most declarations the `<style>` sheets of a document may hold, each block counted once.
const MAX_CSS_DECLARATIONS: usize = 50_000;
/// The most matching work (candidate rules checked plus declarations applied) the elements of a
/// document may cost, `<use>` copies included: a bound on elements x rules x declarations.
const MAX_CSS_WORK: usize = 50_000_000;
/// A CSS rule: selector (type, class, id; `*`) with its specificity and declaration block.
#[derive(Clone, Debug)]
struct Rule {
tag: Option<String>,
@@ -400,7 +410,39 @@ struct Rule {
id: Option<String>,
specificity: u32,
order: usize,
decls: Vec<(String, String)>,
/// Index into [`StyleSheet::blocks`]: every selector of a comma list shares one block.
block: usize,
}
/// The rules of a document's `<style>` sheets, indexed by the part of the selector an element
/// must have (id, else first class, else type) so an element only checks rules that can match.
#[derive(Default, Debug)]
struct StyleSheet {
rules: Vec<Rule>,
blocks: Vec<Vec<(String, String)>>,
/// Declarations over all blocks.
decls: usize,
by_id: HashMap<String, Vec<usize>>,
by_class: HashMap<String, Vec<usize>>,
by_tag: HashMap<String, Vec<usize>>,
universal: Vec<usize>,
}
impl StyleSheet {
fn index(&mut self) {
for (i, r) in self.rules.iter().enumerate() {
let bucket = if let Some(id) = &r.id {
self.by_id.entry(id.clone()).or_default()
} else if let Some(c) = r.classes.first() {
self.by_class.entry(c.clone()).or_default()
} else if let Some(t) = &r.tag {
self.by_tag.entry(t.clone()).or_default()
} else {
&mut self.universal
};
bucket.push(i);
}
}
}
fn parse_decls(s: &str) -> Vec<(String, String)> {
@@ -428,7 +470,9 @@ fn strip_comments(s: &str) -> String {
out
}
fn parse_css(css: &str, rules: &mut Vec<Rule>) {
/// Add the rules of one `<style>` sheet. Errs once the sheets exceed [`MAX_CSS_SELECTORS`] or
/// [`MAX_CSS_DECLARATIONS`] (#2140).
fn parse_css(css: &str, sheet: &mut StyleSheet) -> Result<(), Error> {
let css = strip_comments(css);
let mut rest = css.as_str();
while let Some(open) = rest.find('{') {
@@ -439,13 +483,28 @@ fn parse_css(css: &str, rules: &mut Vec<Rule>) {
if sel.starts_with('@') {
continue;
}
let decls = parse_decls(body);
let mut decls = parse_decls(body);
sheet.decls = sheet.decls.saturating_add(decls.len());
if sheet.decls > MAX_CSS_DECLARATIONS {
return Err(Error::Xml(format!("its <style> sheets hold more than {MAX_CSS_DECLARATIONS} declarations")));
}
// Stored with the rule's first accepted selector: a rule of unsupported selectors only
// (`a b{}`, repeated) adds no block.
let mut block = None;
for one in sel.split(',') {
let one = one.trim();
// Only simple selectors (no combinators / pseudo-classes / attributes).
if one.is_empty() || one.contains([' ', '>', '+', '~', ':', '[']) {
continue;
}
if sheet.rules.len() >= MAX_CSS_SELECTORS {
return Err(Error::Xml(format!("its <style> sheets hold more than {MAX_CSS_SELECTORS} selectors")));
}
let block = *block.get_or_insert_with(|| {
let i = sheet.blocks.len();
sheet.blocks.push(std::mem::take(&mut decls));
i
});
let mut tag = None;
let mut classes = vec![];
let mut id = None;
@@ -474,11 +533,14 @@ fn parse_css(css: &str, rules: &mut Vec<Rule>) {
}
}
flush(kind, &mut cur, &mut tag, &mut classes, &mut id);
let specificity = id.is_some() as u32 * 100 + classes.len() as u32 * 10 + tag.is_some() as u32;
let order = rules.len();
rules.push(Rule { tag, classes, id, specificity, order, decls: decls.clone() });
let specificity = (id.is_some() as u32 * 100)
.saturating_add(u32::try_from(classes.len()).unwrap_or(u32::MAX).saturating_mul(10))
.saturating_add(tag.is_some() as u32);
let order = sheet.rules.len();
sheet.rules.push(Rule { tag, classes, id, specificity, order, block });
}
}
Ok(())
}
const PROPS: &[&str] = &[
@@ -505,7 +567,9 @@ const PROPS: &[&str] = &[
struct Parser<'a, 'i> {
ids: HashMap<String, roxmltree::Node<'a, 'i>>,
rules: Vec<Rule>,
css: StyleSheet,
/// CSS matching work done so far; past [`MAX_CSS_WORK`] rules stop applying and the import errs.
css_work: std::cell::Cell<usize>,
vw: f64,
vh: f64,
skipped: Vec<String>,
@@ -531,24 +595,7 @@ impl<'a, 'i> Parser<'a, 'i> {
out.insert(a.name().to_string(), a.value().to_string());
}
}
let tag = n.tag_name().name();
let classes: Vec<&str> = n.attribute("class").map(|c| c.split_whitespace().collect()).unwrap_or_default();
let id = n.attribute("id");
let mut matching: Vec<&Rule> = self
.rules
.iter()
.filter(|r| {
r.tag.as_deref().is_none_or(|t| t == tag)
&& r.id.as_deref().is_none_or(|i| Some(i) == id)
&& r.classes.iter().all(|c| classes.contains(&c.as_str()))
})
.collect();
matching.sort_by_key(|r| (r.specificity, r.order));
for r in matching {
for (k, v) in &r.decls {
out.insert(k.clone(), v.clone());
}
}
self.apply_rules(n, &mut out);
if let Some(st) = n.attribute("style") {
for (k, v) in parse_decls(st) {
out.insert(k, v);
@@ -557,6 +604,59 @@ impl<'a, 'i> Parser<'a, 'i> {
out
}
/// Charge `n` units of CSS work; false once the budget is spent.
fn charge_css(&self, n: usize) -> bool {
let w = self.css_work.get().saturating_add(n);
self.css_work.set(w);
w <= MAX_CSS_WORK
}
/// Apply the matching `<style>` rules to `out`, by specificity then source order.
fn apply_rules(&self, n: roxmltree::Node, out: &mut HashMap<String, String>) {
let css = &self.css;
if css.rules.is_empty() {
return;
}
// Splitting and sorting the class list is work too: charge its length first.
let class = n.attribute("class").unwrap_or_default();
if !self.charge_css(class.len()) {
return;
}
let tag = n.tag_name().name();
let mut classes: Vec<&str> = class.split_whitespace().collect();
classes.sort_unstable();
classes.dedup();
let id = n.attribute("id");
// Each rule sits in exactly one bucket and the classes are distinct: no rule twice.
let mut buckets: Vec<&Vec<usize>> = vec![&css.universal];
buckets.extend(id.and_then(|i| css.by_id.get(i)));
buckets.extend(classes.iter().filter_map(|c| css.by_class.get(*c)));
buckets.extend(css.by_tag.get(tag));
if !self.charge_css(buckets.iter().map(|b| b.len()).fold(0, usize::saturating_add)) {
return;
}
let candidates = || buckets.iter().flat_map(|b| b.iter()).filter_map(|&i| css.rules.get(i));
// A selector like `.z.z.z…` checks each of its classes: charge them too.
if !self.charge_css(candidates().map(|r| r.classes.len()).fold(0, usize::saturating_add)) {
return;
}
let mut matching: Vec<&Rule> = candidates()
.filter(|r| {
r.tag.as_deref().is_none_or(|t| t == tag)
&& r.id.as_deref().is_none_or(|i| Some(i) == id)
&& r.classes.iter().all(|c| classes.binary_search(&c.as_str()).is_ok())
})
.collect();
matching.sort_by_key(|r| (r.specificity, r.order));
let blocks: Vec<&Vec<(String, String)>> = matching.iter().filter_map(|r| css.blocks.get(r.block)).collect();
if !self.charge_css(blocks.iter().map(|b| b.len()).fold(0, usize::saturating_add)) {
return;
}
for (k, v) in blocks.into_iter().flatten() {
out.insert(k.clone(), v.clone());
}
}
fn length(&self, s: &str, axis: char, font: f64) -> Option<f64> {
let s = s.trim();
let num_end = s.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'))).unwrap_or(s.len());
@@ -621,7 +721,11 @@ impl<'a, 'i> Parser<'a, 'i> {
if let Some(c) = d.get("color").and_then(|v| parse_color(v)) {
st.color = c;
}
if let Some(v) = d.get("font-size").and_then(|v| self.length(v, 'o', st.font_size)) {
let font_size = d.get("font-size").and_then(|v| match v.trim().strip_suffix('%') {
Some(p) => p.trim().parse::<f64>().ok().map(|p| p / 100.0 * st.font_size),
None => self.length(v, 'o', st.font_size),
});
if let Some(v) = font_size.filter(|v| v.is_finite()) {
st.font_size = v;
}
if let Some(p) = d.get("fill").and_then(|v| self.paint_ref(v)) {
@@ -1147,20 +1251,31 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
return Err(Error::NotSvg);
}
let mut ids = HashMap::new();
let mut rules = vec![];
let mut css = StyleSheet::default();
for n in root.descendants().filter(|n| n.is_element()) {
if let Some(id) = n.attribute("id") {
ids.entry(id.to_string()).or_insert(n);
}
if n.tag_name().name() == "style" {
let css: String = n.children().filter_map(|c| c.text()).collect();
parse_css(&css, &mut rules);
let text: String = n.children().filter_map(|c| c.text()).collect();
parse_css(&text, &mut css)?;
}
}
css.index();
let vb = viewbox(root);
// Room for generous reuse, and always for every element of the file itself.
let budget = MAX_EXPANDED_ELEMENTS.max(root.descendants().filter(|n| n.is_element()).count().saturating_mul(2));
let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![], work: 0, budget, using: vec![] };
let mut p = Parser {
ids,
css,
css_work: std::cell::Cell::new(0),
vw: vb.map_or(300.0, |v| v.1),
vh: vb.map_or(150.0, |v| v.2),
skipped: vec![],
work: 0,
budget,
using: vec![],
};
let st = Style::default();
let pct_or = |name: &str, axis: char, def: f64| -> f64 {
match root.attribute(name) {
@@ -1200,6 +1315,10 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
// Never a silently partial drawing.
return Err(Error::Xml(format!("its <use> references expand to more than {} elements", p.budget)));
}
if p.css_work.get() > MAX_CSS_WORK {
// Never a silently unstyled drawing.
return Err(Error::Xml(format!("matching its <style> rules takes more than {MAX_CSS_WORK} steps")));
}
let transform = viewbox_transform(root, dw, dh);
Ok(Doc {
width: dw,
+126
View File
@@ -296,3 +296,129 @@ fn a_symbol_used_a_thousand_times_still_imports() {
let doc = parse(svg.as_bytes()).unwrap();
assert_eq!(depth_and_shapes(&doc.root).1, 3000);
}
/// An SVG with one `<style>` sheet and one rectangle per given class list.
fn styled(css: &str, rects: &[&str]) -> Vec<u8> {
let rects: String = rects.iter().map(|attrs| format!(r#"<rect width="1" height="1" {attrs}/>"#)).collect();
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><style>{css}</style>{rects}</svg>"#).into_bytes()
}
#[test]
fn comma_selector_lists_share_one_declaration_block() {
// Before #2140 every selector of a comma list got its own copy of the declaration block, so
// `a,a,a…{…}` stored selectors × declarations strings; one selector past the cap is refused.
let sels = vec!["rect"; MAX_CSS_SELECTORS + 1].join(",");
let decls: String = (0..10).map(|i| format!("p{i}:v;")).collect();
let svg = styled(&format!("{sels}{{fill:red;{decls}}}"), &[""]);
let e = parse(&svg).unwrap_err();
assert!(e.to_string().contains("selectors"), "{e}");
}
#[test]
fn a_style_sheet_with_too_many_declarations_errors() {
let decls: String = (0..=MAX_CSS_DECLARATIONS).map(|i| format!("p{i}:v;")).collect();
let svg = styled(&format!("rect{{{decls}}}"), &[""]);
let e = parse(&svg).unwrap_err();
assert!(e.to_string().contains("declarations"), "{e}");
}
#[test]
fn selector_lists_keep_the_css_cascade() {
let green = Some(Paint::Color([0.0, 1.0, 0.0]));
let blue = Some(Paint::Color([0.0, 0.0, 1.0]));
let red = Some(Paint::Color([1.0, 0.0, 0.0]));
let css = "rect, .b, #c { fill: #00ff00 } .b, .d { fill: #0000ff; stroke: #000000; stroke-width: 3 } rect.e, #c { fill: #ff0000 }";
let svg = styled(css, &["", r#"class="b""#, r#"id="c" class="b""#, r#"class="b" style="fill:#00ff00""#, r##"class="e" fill="#0000ff""##, r#"class="d""#]);
let doc = parse(&svg).unwrap();
let shapes = doc.flatten();
let fills: Vec<_> = shapes.iter().map(|s| s.2.fill.clone()).collect();
// Type selector; class beats type; id beats class; inline style beats every rule; a later,
// more specific rule beats the presentation attribute.
assert_eq!(fills, vec![green.clone(), blue.clone(), red.clone(), green, red, blue]);
assert!(shapes[0].2.stroke.is_none());
// The second list's block reaches both of its selectors.
for i in [1, 5] {
assert_eq!(shapes[i].2.stroke.as_ref().map(|s| s.width), Some(3.0), "shape {i}");
}
}
#[test]
fn a_comma_list_stores_its_declarations_once() {
let sels = vec!["rect"; 1000].join(",");
let decls: String = (0..50).map(|i| format!("p{i}:v;")).collect();
let mut sheet = StyleSheet::default();
parse_css(&format!("{sels}{{{decls}}}"), &mut sheet).unwrap();
assert_eq!(sheet.rules.len(), 1000);
assert_eq!(sheet.blocks.len(), 1);
assert_eq!(sheet.decls, 50);
}
#[test]
fn rules_of_unsupported_selectors_store_no_blocks() {
let mut sheet = StyleSheet::default();
parse_css(&"a b{}".repeat(100_000), &mut sheet).unwrap();
parse_css("a b{fill:red} a>b, rect{fill:blue} c:hover, d{fill:red}", &mut sheet).unwrap();
assert_eq!(sheet.rules.len(), 2);
assert_eq!(sheet.blocks.len(), 2);
assert!(sheet.rules.iter().all(|r| sheet.blocks.get(r.block).is_some_and(|b| b.len() == 1)));
}
#[test]
fn huge_class_lists_count_against_the_matching_budget() {
// One rule, but a 1 MB class attribute re-split on each of 100 `<use>` copies (#2140).
let class = "a ".repeat(500_000);
let uses: String = (0..100).map(|i| format!(r##"<use href="#r" x="{i}"/>"##)).collect();
let svg = |uses: &str| {
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><style>.z{{fill:red}}</style><defs><rect id="r" width="1" height="1" class="{class}"/></defs>{uses}</svg>"##
)
};
let e = parse(svg(&uses).as_bytes()).unwrap_err();
assert!(e.to_string().contains("<style> rules"), "{e}");
let few: String = uses.split_inclusive("/>").take(10).collect();
assert!(parse(svg(&few).as_bytes()).is_ok());
}
#[test]
fn a_selector_with_many_classes_counts_against_the_matching_budget() {
// `.z.z.z…` lands in one class bucket, but each match checks every class it lists.
let rule = ".z".repeat(300_000);
let uses: String = (0..200).map(|i| format!(r##"<use href="#r" x="{i}"/>"##)).collect();
let svg = |uses: &str| {
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><style>{rule}{{fill:red}}</style><defs><rect id="r" width="1" height="1" class="z"/></defs>{uses}</svg>"##
)
};
let e = parse(svg(&uses).as_bytes()).unwrap_err();
assert!(e.to_string().contains("<style> rules"), "{e}");
let few: String = uses.split_inclusive("/>").take(10).collect();
assert!(parse(svg(&few).as_bytes()).is_ok());
}
#[test]
fn matching_work_past_the_budget_errors_without_doing_it() {
// Within both sheet caps, yet one rectangle would apply 10 000 x 5 001 declarations.
let sels = vec!["rect"; 10_000].join(",");
let decls: String = (0..5_001).map(|i| format!("p{i}:v;")).collect();
let svg = styled(&format!("{sels}{{{decls}}}"), &[""]);
let e = parse(&svg).unwrap_err();
assert!(e.to_string().contains("<style> rules"), "{e}");
// The same sheet styles an element it does not match at no cost.
let ok = styled(&format!("{}{{{decls}}}", vec!["circle"; 10_000].join(",")), &[""]);
assert!(parse(&ok).is_ok());
}
#[test]
fn font_size_percentage_resolves_against_the_parent_font_size() {
for (body, expected) in [
(r#"<g font-size="200%"><rect width="1em" height="1em"/></g>"#, 32.0),
(r#"<g font-size="200%"><rect width="2ex" height="1em"/></g>"#, 32.0),
(r#"<g font-size="200%"><g font-size="200%"><rect width="1em" height="1em"/></g></g>"#, 64.0),
] {
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">{body}</svg>"#);
let d = parse(svg.as_bytes()).unwrap();
let shapes = d.flatten();
assert_eq!(shapes.len(), 1);
assert!(matches!(shapes[0].2.geom, Geom::Rect { w, .. } if w == expected), "{body}: {:?}", shapes[0].2.geom);
}
}
+10
View File
@@ -588,6 +588,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "形状图层"),
("layer.newAdjustment", "Adjustment Layer", "调整图层"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "内容识别填充图层..."),
("layer.newPrimitive", "3D Primitive...", "3D 基本体..."),
("layer.new3dPrimitive", "Cube", "立方体"),
("layer.new3dPrimitive", "Sphere", "球体"),
("layer.new3dPrimitive", "Plane", "平面"),
@@ -1222,6 +1223,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "形狀圖層"),
("layer.newAdjustment", "Adjustment Layer", "調整圖層"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "內容感知填滿圖層..."),
("layer.newPrimitive", "3D Primitive...", "3D 基本體..."),
("layer.new3dPrimitive", "Cube", "立方體"),
("layer.new3dPrimitive", "Sphere", "球體"),
("layer.new3dPrimitive", "Plane", "平面"),
@@ -1853,6 +1855,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "シェイプレイヤー"),
("layer.newAdjustment", "Adjustment Layer", "調整レイヤー"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "コンテンツに応じた塗りつぶしレイヤー..."),
("layer.newPrimitive", "3D Primitive...", "3D プリミティブ..."),
("layer.new3dPrimitive", "Cube", "立方体"),
("layer.new3dPrimitive", "Sphere", "球体"),
("layer.new3dPrimitive", "Plane", "平面"),
@@ -2487,6 +2490,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Шар фігури"),
("layer.newAdjustment", "Adjustment Layer", "Коригувальний шар"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Шар заливки з урахуванням вмісту..."),
("layer.newPrimitive", "3D Primitive...", "3D-примітив..."),
("layer.new3dPrimitive", "Cube", "Куб"),
("layer.new3dPrimitive", "Sphere", "Сфера"),
("layer.new3dPrimitive", "Plane", "Площина"),
@@ -3122,6 +3126,7 @@ const SPANISH: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Capa de forma"),
("layer.newAdjustment", "Adjustment Layer", "Capa de ajuste"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Capa de relleno según el contenido..."),
("layer.newPrimitive", "3D Primitive...", "Primitiva 3D..."),
("layer.new3dPrimitive", "Cube", "Cubo"),
("layer.new3dPrimitive", "Sphere", "Esfera"),
("layer.new3dPrimitive", "Plane", "Plano"),
@@ -3755,6 +3760,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Camada de forma"),
("layer.newAdjustment", "Adjustment Layer", "Camada de ajuste"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Camada de preenchimento sensível ao conteúdo..."),
("layer.newPrimitive", "3D Primitive...", "Primitiva 3D..."),
("layer.new3dPrimitive", "Cube", "Cubo"),
("layer.new3dPrimitive", "Sphere", "Esfera"),
("layer.new3dPrimitive", "Plane", "Plano"),
@@ -4395,6 +4401,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Слой-фигура"),
("layer.newAdjustment", "Adjustment Layer", "Корректирующий слой"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Слой заливки с учетом содержимого..."),
("layer.newPrimitive", "3D Primitive...", "3D-примитив..."),
("layer.new3dPrimitive", "Cube", "Куб"),
("layer.new3dPrimitive", "Sphere", "Сфера"),
("layer.new3dPrimitive", "Plane", "Плоскость"),
@@ -5028,6 +5035,7 @@ const FRENCH: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Calque de forme"),
("layer.newAdjustment", "Adjustment Layer", "Calque de réglage"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Calque de remplissage d’après le contenu..."),
("layer.newPrimitive", "3D Primitive...", "Primitive 3D..."),
("layer.new3dPrimitive", "Cube", "Cube"),
("layer.new3dPrimitive", "Sphere", "Sphère"),
("layer.new3dPrimitive", "Plane", "Plan"),
@@ -5661,6 +5669,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Formebene"),
("layer.newAdjustment", "Adjustment Layer", "Einstellungsebene"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Ebene für inhaltsbasierte Füllung..."),
("layer.newPrimitive", "3D Primitive...", "3D-Grundkörper..."),
("layer.new3dPrimitive", "Cube", "Würfel"),
("layer.new3dPrimitive", "Sphere", "Kugel"),
("layer.new3dPrimitive", "Plane", "Fläche"),
@@ -6295,6 +6304,7 @@ const CZECH: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Vrstva tvaru"),
("layer.newAdjustment", "Adjustment Layer", "Vrstva úprav"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Vrstva výplně podle obsahu..."),
("layer.newPrimitive", "3D Primitive...", "3D primitivum..."),
("layer.new3dPrimitive", "Cube", "Krychle"),
("layer.new3dPrimitive", "Sphere", "Koule"),
("layer.new3dPrimitive", "Plane", "Rovina"),
+10 -3
View File
@@ -336,9 +336,10 @@ const JAPANESE: &[(&str, &str)] = &[
("Follow Feature Rotation and Scale", "特徴の回転とスケールに追従"),
("Footage: {}", "フッテージ:{}"),
("Format: {}", "形式:{}"),
("Frame Aspect Ratio: {}", "フレームアスペクト比:{}"),
("Frame Aspect Ratio: {}", "フレーム縦横比:{}"),
("Frame Blending: {}", "フレームブレンド:{}"),
("Frame Rate:", "フレームレート:"),
("Resolution:", "解像度:"),
("Frame Render Time {}ms", "フレームのレンダリング時間 {}ms"),
("Frame Target:", "フレームターゲット:"),
("Freeze", "フリーズ"),
@@ -385,7 +386,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Lighter", "明るく"),
("Link Selected Property", "選択したプロパティをリンク"),
("Location:", "位置:"),
("Lock Aspect Ratio to {}", "アスペクト比を{}に固定"),
("Lock Aspect Ratio to {}", "縦横比を {} に固定"),
("Lock to Zoom", "ズームにロック"),
("Locked: {} — {}", "ロック済み:{} — {}"),
("Log:", "ログ:"),
@@ -468,7 +469,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Overlay", "オーバーレイ"),
("Parameter Name:", "パラメーター名:"),
("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"),
("Pixel Aspect Ratio:", "ピクセルアスペクト比:"),
("Pixel Aspect Ratio:", "ピクセル縦横比:"),
("Play/Stop", "再生/停止"),
("Playing on Video Preview", "ビデオプレビューで再生中"),
("Post-Render Action: {}", "レンダリング後の処理:{}"),
@@ -1380,6 +1381,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "帧长宽比:{}"),
("Frame Blending: {}", "帧混合:{}"),
("Frame Rate:", "帧速率:"),
("Resolution:", "分辨率:"),
("Frame Render Time {}ms", "帧渲染时间 {} 毫秒"),
("Frame Target:", "帧目标:"),
("Freeze", "冻结"),
@@ -2423,6 +2425,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "影格長寬比:{}"),
("Frame Blending: {}", "影格混合:{}"),
("Frame Rate:", "影格速率:"),
("Resolution:", "解析度:"),
("Frame Render Time {}ms", "影格算圖時間 {} 毫秒"),
("Frame Target:", "幀目標:"),
("Freeze", "凍結"),
@@ -3419,6 +3422,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "Пропорції кадру: {}"),
("Frame Blending: {}", "Змішування кадрів: {}"),
("Frame Rate:", "Частота кадрів:"),
("Resolution:", "Роздільність:"),
("Frame Render Time {}ms", "Час рендерингу кадру {} мс"),
("Frame Target:", "Цільовий фрейм:"),
("Freeze", "Заморозити"),
@@ -3979,6 +3983,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "Proporção do quadro: {}"),
("Frame Blending: {}", "Mesclagem de quadros: {}"),
("Frame Rate:", "Taxa de quadros:"),
("Resolution:", "Resolução:"),
("Frame Render Time {}ms", "Tempo de renderização do quadro {}ms"),
("Frame Target:", "Destino do quadro:"),
("Grab bag", "Lista"),
@@ -4437,6 +4442,7 @@ const CZECH: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "Poměr stran snímku: {}"),
("Frame Blending: {}", "Prolnutí snímků: {}"),
("Frame Rate:", "Snímková frekvence:"),
("Resolution:", "Rozlišení:"),
("Frame Render Time {}ms", "Čas vykreslení snímku {}ms"),
("Frame Target:", "Cílový rámec:"),
("Freeze", "Zmrazit"),
@@ -5000,6 +5006,7 @@ const SPANISH: &[(&str, &str)] = &[
("Frame Aspect Ratio: {}", "Relación de aspecto del fotograma: {}"),
("Frame Blending: {}", "Fusión de fotogramas: {}"),
("Frame Rate:", "Velocidad de fotogramas:"),
("Resolution:", "Resolución:"),
("Frame Render Time {}ms", "Tiempo de procesamiento del fotograma {}ms"),
("Frame Target:", "Marco de destino:"),
("Freeze", "Congelar"),
+1
View File
@@ -72,6 +72,7 @@ pub enum Dialog {
ViewOptions,
CameraSettings,
LightSettings,
NewPrimitive,
KeyVelocity,
KeyInterpolation,
TimeStretch,
+38 -1
View File
@@ -10,7 +10,7 @@
//! shortcuts and the panels don't see them (Space must not start a preview).
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Response, Ui};
use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Rect, Response, Ui};
/// An entry of an open menu as drawn this frame.
#[derive(Clone, Copy)]
@@ -39,6 +39,12 @@ pub struct Nav {
tops: Vec<(Id, Id)>,
/// Open menus by depth: the top-level menu, then each open submenu.
levels: Vec<Level>,
/// The titles and open menus drawn this frame: a press outside them is an outside press,
/// which closes the menu (native behaviour; the press itself still reaches what's under it).
rects: Vec<Rect>,
/// A title press opened (or kept) the menu this frame: releasing the button over an entry
/// chooses it (After Effects' press-and-browse).
browsing: bool,
}
/// What to highlight once a menu opened from the keyboard has been drawn: the first entry of the
@@ -73,6 +79,37 @@ impl Nav {
self.tops.push((button.id, Popup::default_response_id(button)));
}
/// The rect `rect` (a menu title or an open menu) holds, so a press there doesn't close.
pub fn cover(&mut self, rect: Rect) {
self.rects.push(rect);
}
/// Whether `pos` is inside a title or menu drawn this frame.
pub fn covers(&self, pos: Pos2) -> bool {
self.rects.iter().any(|r| r.contains(pos))
}
/// A title press is in progress (its button still held): see [`Nav::browsing`].
pub fn set_browsing(&mut self) {
self.browsing = true;
}
/// Whether the menu bar is mid press-and-browse (a title was pressed this pointer-down and
/// hasn't been released yet): releasing over an entry then chooses it.
pub fn browsing(&self) -> bool {
self.browsing
}
/// The popup of the open top-level menu (only one can be open), if any.
pub fn open_popup(&self, ctx: &Context) -> Option<Id> {
self.tops.iter().find(|(_, p)| Popup::is_id_open(ctx, *p)).map(|(_, p)| *p)
}
/// The position in the bar of the open top-level menu, if any.
pub fn open_index(&self, ctx: &Context) -> Option<usize> {
self.tops.iter().position(|(_, p)| Popup::is_id_open(ctx, *p))
}
/// An entry drawn at `depth` (0: a top-level menu) in the menu `ui`. Hovering an entry moves
/// the keyboard highlight to it, and a highlighted entry scrolls into view.
pub fn entry(&mut self, ui: &Ui, depth: usize, r: &Response, submenu: bool) {
+132 -15
View File
@@ -399,6 +399,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::dialogs_3d::route(app, id, &params)? {
return Ok(Value::Null);
}
// New 3D Primitive without parameters opens its dialog.
if crate::panels::primitive_dialog::route(app, id, &params)? {
return Ok(Value::Null);
}
// Layer Settings on a solid, adjustment layer or null opens its settings dialog.
if crate::panels::dialogs::route_layer_settings(app, id, &params)? {
return Ok(Value::Null);
@@ -2033,32 +2037,119 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
}
}
/// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last
/// frame (its title's open look, and the hover that switches between the titles).
fn open_menu_id() -> egui::Id {
egui::Id::new("menu-bar-open")
}
/// The id under which [`menu_bar`] remembers a title press still in progress (press-and-browse).
fn browse_menu_id() -> egui::Id {
egui::Id::new("menu-bar-browse")
}
/// Draw the in-window menu bar (the engine's AE menu tree).
///
/// The titles open on the press, not the release (native menu bars): while one is held the menus
/// can be browsed by moving onto them, and releasing over an entry chooses it. Hovering another
/// title switches the open menu to it, and the open one closes on a press outside it or when the
/// window loses focus. The pointer leaving the window — onto the OS title bar, directly above the
/// bar — closes nothing, however long: the platform forwards none of the title bar's clicks to
/// egui (only the cursor leaving), so there is no click to close on and a graze must not drop the
/// menu. The closing press itself still reaches the widget under it, as a click does in After
/// Effects.
pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let mut clicked: Option<(String, Value)> = None;
let mut nav = crate::menu_keys::Nav::default();
// The open menu as of the last frame: the titles are drawn before this frame can open or
// close anything, and what happened last frame is what they must react to.
let open_prev: Option<usize> = ctx.data(|d| d.get_temp(open_menu_id())).flatten();
if ctx.data(|d| d.get_temp(browse_menu_id())).unwrap_or(false) {
nav.set_browsing();
}
let mut drawn = 0;
ui.horizontal_centered(|ui| {
ui.add_space(6.0);
egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::widgets::menu_style)).ui(ui, |ui| {
for node in effectcraft_engine::menus::menu_bar() {
if let MenuNode::Submenu { label, children } = node {
let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| {
ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 });
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0));
});
nav.top(&r.response);
app.auto.add(&format!("menu.{label}"), r.response.rect, label);
let MenuNode::Submenu { label, children } = node else { continue };
// The open title takes the open button's look while its menu is up (as in AE).
let inactive = ui.style().visuals.widgets.inactive;
if open_prev == Some(drawn) {
ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open;
}
let mut response = ui.add(egui::Button::new(crate::i18n::label(app, "", label)));
ui.style_mut().visuals.widgets.inactive = inactive;
// A press on a title opens its menu at once (a second press on the open one closes
// it), and while it is held the menus below are browsable. The keyboard and the
// accessibility activation report `clicked` without the primary button (the press
// then happened somewhere else), and toggle the menu. Hovering another title
// switches to it once the pointer has moved: merely resting there must not take a
// keyboard-driven menu over.
let moved = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO);
// With the button down elsewhere nothing is hovered, so the title under the
// pointer takes the highlight by hand: the hover-switch to it shows, one frame
// later like egui's own hover (the open title keeps its open look).
if moved && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) {
response = response.highlight();
}
let pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) && response.hovered();
let cmd = if pressed {
nav.set_browsing();
Some(egui::SetOpenCommand::Bool(open_prev != Some(drawn)))
} else if response.clicked() && !response.clicked_by(egui::PointerButton::Primary) {
Some(egui::SetOpenCommand::Toggle)
} else if moved && response.enabled() && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) {
Some(egui::SetOpenCommand::Bool(true))
} else {
None
};
let inner = egui::Popup::menu(&response)
.close_behavior(egui::PopupCloseBehavior::IgnoreClicks)
.style(crate::widgets::menu_style)
.open_memory(cmd)
.show(|ui| {
ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 });
let browsing = nav.browsing();
crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0));
});
nav.top(&response);
// The titles and the open menus: a press inside them is not an outside press.
nav.cover(response.rect);
if let Some(inner) = &inner {
nav.cover(inner.response.rect);
}
app.auto.add(&format!("menu.{label}"), response.rect, label);
drawn += 1;
}
});
});
crate::menu_keys::handle(&ctx, &nav);
// egui leaves the closing to us (`IgnoreClicks`), so the menus close here: at once on a press
// outside them (the press itself still reaches the widget below) and when the window loses
// focus. The pointer leaving the window closes nothing: moving onto the OS title bar fires
// the cursor-left event and the platform forwards none of its clicks to egui, so there is no
// click to close on — and a graze or a stay must not drop the menu, as in native apps.
if let Some(top) = nav.open_popup(&ctx) {
let focus_lost = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))));
let outside_press = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !nav.covers(p)));
if focus_lost || outside_press {
egui::Popup::close_id(&ctx, top);
}
}
if let Some((id, params)) = clicked.map(|(id, params)| menu_choice(app, id, params))
&& let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params })
{
app.ui.status = e;
}
// What the next frame starts from: the open title, and a title press still in progress.
let open = nav.open_index(&ctx);
let browsing = open.is_some() && nav.browsing() && ctx.input(|i| i.pointer.primary_down());
ctx.data_mut(|d| {
d.insert_temp(open_menu_id(), open);
d.insert_temp(browse_menu_id(), browsing);
});
}
/// The entries of top-level menu `name` (the Effect menu is the Effect Controls panel's context
@@ -2110,9 +2201,10 @@ fn menu_nodes(
Some(t) => r.on_hover_text(t),
None => r,
};
let r = browse_hover(nav, r);
app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label);
nav.entry(ui, depth, &r, false);
if r.clicked() {
if r.clicked() || browse_pick(nav, ui, &r) {
*clicked = Some((e.command.clone(), e.params.clone()));
ui.close();
}
@@ -2124,19 +2216,27 @@ fn menu_nodes(
// every other submenu goes through the menu catalog.
let shown = crate::i18n::effect_category(children, label)
.unwrap_or_else(|| crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[]))));
let r = ui.menu_button((gutter(false), shown.as_str()), |ui| {
let browsing = nav.browsing();
let egui::InnerResponse { inner, response } = ui.menu_button((gutter(false), shown.as_str()), |ui| {
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1));
crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1));
});
nav.entry(ui, depth, &r.response, true);
if r.inner.is_some() {
nav.opened(depth, &r.response);
let response = browse_hover(nav, response);
nav.entry(ui, depth, &response, true);
if inner.is_some() {
// `ui.menu_button` drops the popup's own response on the floor: read it back,
// so that a press inside this submenu doesn't count as an outside press.
let sub = egui::containers::menu::SubMenu::id_from_widget_id(response.id);
if let Some(resp) = ui.ctx().read_response(sub) {
nav.cover(resp.rect);
}
nav.opened(depth, &response);
}
}
MenuNode::Item(e) => {
let r = menu_entry(app, ui, e);
let r = browse_hover(nav, menu_entry(app, ui, e));
nav.entry(ui, depth, &r, false);
if r.clicked() {
if r.clicked() || browse_pick(nav, ui, &r) {
*clicked = Some((e.command.clone(), e.params.clone()));
ui.close();
}
@@ -2145,6 +2245,23 @@ fn menu_nodes(
}
}
/// The row `r` highlighted while the bar is mid press-and-browse: with a button down egui reports
/// nothing as hovered — which would leave browsing with no hover feedback at all — so the row
/// under the pointer takes the highlight by hand. It reads as the hover look one frame later,
/// exactly egui's own hover latency; disabled rows ignore it (`Noninteractive`), as in native
/// menus. Context menus use their own [`crate::menu_keys::Nav`], where the bar never browses.
fn browse_hover(nav: &crate::menu_keys::Nav, r: egui::Response) -> egui::Response {
if nav.browsing() && r.contains_pointer() { r.highlight() } else { r }
}
/// Whether a press-and-browse (a menu-bar title still held down) releases over entry `r`, which
/// then chooses it like a click does. egui reports neither for it: the release is far from the
/// press, so there is no click, and nothing but the pressed widget is hovered while a button is
/// down, so the row has to be tested by the pointer being over it.
fn browse_pick(nav: &crate::menu_keys::Nav, ui: &egui::Ui, r: &egui::Response) -> bool {
nav.browsing() && r.enabled() && r.contains_pointer() && ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary))
}
/// The menu bar's submenu at `path` (`["File", "Interpret Footage"]`) in a panel's context menu,
/// with the menu bar's labels, shortcuts and enabled state; a chosen entry goes to `clicked`.
pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&str], clicked: &mut Option<(String, Value)>) {
+7 -1
View File
@@ -184,7 +184,13 @@ pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let fr = c.frame_rate;
let (s, e) = (app.session.time(), c.work_area.1);
let tc = |x| crate::panels::timecode(&app.session, c, x);
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, tr_args("Start: {} Duration: {}", &[&(tc(s)), &(tc(e - s))]), Tokens::ui(11.0), t.text_faint);
p.text(
pos2(x0, y + 8.0),
Align2::LEFT_CENTER,
tr_args("Start: {} Duration: {}", &[&(tc(s)), &crate::panels::duration(&app.session, c, e - s)]),
Tokens::ui(11.0),
t.text_faint,
);
let _ = fr;
y += ROW;
let b = Rect::from_min_size(pos2(x0, y), vec2(120.0, 24.0));
+45 -3
View File
@@ -5,6 +5,7 @@
use crate::i18n::{tr, tr_args};
use crate::icons::Icon;
use effectcraft_engine::prefs::CompPreset;
use effectcraft_engine::project::Resolution;
use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode};
use egui::{Color32, vec2};
use serde_json::json;
@@ -94,6 +95,9 @@ pub struct CompDraft {
pub preserve_frame_rate: bool,
pub preserve_resolution: bool,
pub advanced_3d: bool,
/// The comp's Resolution (the Composition panel's popup): saved with the project but no undo
/// step, so OK sets it apart from `comp.new` / `comp.settings`.
pub resolution: Resolution,
pub tab: Tab,
/// Timecode text being edited (start, duration).
pub start_tc: Option<String>,
@@ -122,6 +126,7 @@ impl Default for CompDraft {
preserve_frame_rate: false,
preserve_resolution: false,
advanced_3d: false,
resolution: Resolution::Auto,
tab: Tab::Basic,
start_tc: None,
dur_tc: None,
@@ -376,9 +381,30 @@ pub fn show(
}
});
ui.end_row();
ui.label("");
let mb = d.width as f64 * d.height as f64 * 4.0 / (1024.0 * 1024.0);
ui.label(egui::RichText::new(tr_args("{} x {}, {}MB per 8bpc frame", &[&d.width, &d.height, &format!("{mb:.1}")])).color(Color32::GRAY));
// As in After Effects, the Resolution menu (the Composition panel's popup) with
// the frame it renders beside it.
ui.label(tr("Resolution:"));
ui.horizontal(|ui| {
let r = egui::ComboBox::from_id_salt("comp-resolution").width(90.0).selected_text(tr(d.resolution.label())).show_ui(ui, |ui| {
for res in Resolution::ALL {
ui.selectable_value(&mut d.resolution, res, tr(res.label()));
}
let custom = matches!(d.resolution, Resolution::Custom(_));
if ui.selectable_label(custom, tr("Custom")).clicked() && !custom {
d.resolution = Resolution::Custom(5);
}
});
auto.add("dialog.comp.resolution", r.response.rect, "Resolution");
if let Resolution::Custom(n) = d.resolution {
let mut n = n;
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut n).range(2..=40)));
auto.add("dialog.comp.resolutionFactor", r.rect, "Custom Resolution");
d.resolution = Resolution::Custom(n);
}
let (w, h) = frame_at(d.width, d.height, d.resolution);
let mb = w as f64 * h as f64 * 4.0 / (1024.0 * 1024.0);
ui.label(egui::RichText::new(tr_args("{} x {}, {}MB per 8bpc frame", &[&w, &h, &format!("{mb:.1}")])).color(Color32::GRAY));
});
ui.end_row();
ui.label(tr("Start Timecode:"));
let r = ui.horizontal(|ui| timecode_field(ui, &mut d.start_tc, &mut d.start, rate, i64::MIN / 4)).inner;
@@ -486,10 +512,26 @@ pub fn params(d: &CompDraft) -> serde_json::Value {
})
}
/// The frame size the comp renders at `res` (Auto follows the magnification, so the dialog shows
/// the full frame for it).
pub fn frame_at(w: u32, h: u32, res: Resolution) -> (u32, u32) {
effectcraft_render::frame_size(w, h, 1.0 / f64::from(res.factor().unwrap_or(1).max(1)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_size_follows_the_resolution() {
// The renderer's size, which rounds: 1081 / 3 = 360.33 and 1920 / 7 = 274.3.
assert_eq!(frame_at(1920, 1081, Resolution::Third), (640, 360));
assert_eq!(frame_at(1920, 1080, Resolution::Custom(7)), (274, 154));
assert_eq!(frame_at(1920, 1081, Resolution::Half), (960, 541));
assert_eq!(frame_at(1920, 1080, Resolution::Full), (1920, 1080));
assert_eq!(frame_at(1920, 1080, Resolution::Auto), (1920, 1080));
}
#[test]
fn aspect_labels_match_ae() {
assert_eq!(aspect_label(1920.0, 1080.0), "16:9 (1.78)");
+70
View File
@@ -47,6 +47,7 @@ pub struct DialogState {
pub info: (String, String),
pub camera: super::dialogs_3d::CameraDraft,
pub light: super::dialogs_3d::LightDraft,
pub primitive: super::primitive_dialog::PrimitiveDraft,
pub velocity: super::key_dialogs::VelocityDraft,
pub interp: super::key_dialogs::InterpDraft,
pub stretch: super::key_dialogs::StretchDraft,
@@ -102,6 +103,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<(
preserve_frame_rate: c.preserve_frame_rate,
preserve_resolution: c.preserve_resolution,
advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D,
resolution: c.resolution,
..Default::default()
};
app.dialog_state.editing_existing = true;
@@ -248,6 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t),
Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t),
Dialog::LightSettings => super::dialogs_3d::light(app, ctx, &t),
Dialog::NewPrimitive => super::primitive_dialog::dialog(app, ctx, &t),
Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t),
Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t),
Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t),
@@ -408,6 +411,11 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
m.insert("comp".into(), json!(cid.0));
}
let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) };
let cid = if existing { app.dialog_state.editing_comp.map(|c| c.0) } else { r.as_ref().ok().and_then(|v| v["comp"].as_u64()) };
let r = match cid {
Some(cid) if r.is_ok() => set_resolution(app, cid, app.dialog_state.comp.resolution).and(r),
_ => r,
};
if !existing && r.is_ok() {
app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone());
}
@@ -420,6 +428,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
}
}
/// The Resolution chosen in Composition Settings, set as the Composition panel's popup sets it:
/// saved with the project but no undo step, so it stays out of `comp.new` / `comp.settings`.
fn set_resolution(app: &mut EffectcraftApp, cid: u64, r: effectcraft_engine::project::Resolution) -> effectcraft_engine::Result<Value> {
match r.factor() {
None => app.session.execute("view.res.auto", json!({"comp": cid})),
Some(n) => app.session.execute("view.res.custom", json!({"comp": cid, "factor": n})),
}
}
/// One frame of the open dialog with `key` pressed.
#[cfg(test)]
pub(crate) fn press_key(app: &mut EffectcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) {
@@ -827,6 +844,59 @@ mod tests {
assert_eq!(selected(&ctx), Some(0..app.dialog_state.solid_name.chars().count()));
}
/// One frame of Composition Settings (or New Composition) with Enter pressed: OK.
fn press_ok(app: &mut EffectcraftApp, ctx: &egui::Context, existing: bool) {
let t = app.tokens;
let mut out = ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
ui.input_mut(|i| {
i.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() })
});
comp_settings(app, ui.ctx(), &t, existing);
});
out.textures_delta.clear();
assert!(app.dialog.is_none(), "OK closed the dialog");
}
/// The Resolution menu sets the comp's Resolution on OK. As from the Composition panel's popup
/// it is no undo step: the settings are one step, and undoing them keeps the Resolution.
#[test]
fn composition_settings_resolution_applies_on_ok_outside_the_undo_history() {
use effectcraft_engine::project::Resolution;
let (mut app, ctx) = app();
let cid = app.session.active_comp_id().unwrap();
app.session.execute("view.res.third", json!({})).unwrap();
open_comp_settings(&mut app).unwrap();
assert_eq!(app.dialog_state.comp.resolution, Resolution::Third, "the dialog opens on the comp's Resolution");
frame(&mut app, &ctx);
assert!(app.auto.find("dialog.comp.resolution").is_some());
let steps = app.session.history.undo.len();
(app.dialog_state.comp.resolution, app.dialog_state.comp.width) = (Resolution::Half, 700);
app.dialog_state.comp.lock_aspect = false;
press_ok(&mut app, &ctx, true);
let c = app.session.project.comp(cid).unwrap();
assert_eq!((c.width, c.resolution), (700, Resolution::Half));
assert_eq!(app.session.history.undo.len(), steps + 1, "Composition Settings is one undo step");
app.session.execute("edit.undo", json!({})).unwrap();
let c = app.session.project.comp(cid).unwrap();
assert_eq!((c.width, c.resolution), (640, Resolution::Half), "undo keeps the Resolution");
// Only the Resolution changed: still set, and no extra step.
open_comp_settings(&mut app).unwrap();
app.dialog_state.comp.resolution = Resolution::Custom(6);
let steps = app.session.history.undo.len();
press_ok(&mut app, &ctx, true);
assert_eq!(app.session.project.comp(cid).unwrap().resolution, Resolution::Custom(6));
assert_eq!(app.session.history.undo.len(), steps, "no undo step");
// New Composition makes its comp at the menu's Resolution; the comp the viewer showed keeps its own.
open_new_comp(&mut app);
app.dialog_state.comp.resolution = Resolution::Quarter;
press_ok(&mut app, &ctx, false);
let new = app.session.active_comp_id().unwrap();
assert_ne!(new, cid);
assert_eq!(app.session.project.comp(new).unwrap().resolution, Resolution::Quarter);
assert_eq!(app.session.project.comp(cid).unwrap().resolution, Resolution::Custom(6));
}
#[test]
fn composition_settings_register_every_control_and_new_comp_remembers() {
let (mut app, ctx) = app();
+1 -1
View File
@@ -980,7 +980,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
let col = channel_color(ch);
p.rect_filled(Rect::from_min_max(pos2(gr.min.x, gr.max.y + 3.0), pos2(gr.max.x, gr.max.y + 8.0)), 0.0, col.gamma_multiply(0.5));
let id = egui::Id::new(("ec-curves", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, pencil, id, &t);
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, pencil, true, id, &t);
app.auto.add(&format!("effectControls.effect.{euid}.curves.graph"), gr, "Curves");
let started = resp.drag_started() || resp.clicked();
if let Some(np) = edit.changed {
+2
View File
@@ -542,6 +542,8 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
}
},
),
// Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
Field::choice(
"colorEngine",
"Color engine",
+2 -2
View File
@@ -193,7 +193,7 @@ fn rgb_curves(
}
let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size));
let gid = egui::Id::new(("lumetri-rgb", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, false, gid, &t);
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, false, false, gid, &t);
app.auto.add(&format!("{prefix}.graph"), gr, "RGB Curves");
register_points(app, &prefix, gr, &cur.0);
let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked());
@@ -713,7 +713,7 @@ fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, laye
}
let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size));
let gid = egui::Id::new(("glow-map", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &map[ch], ch + 1, false, gid, &t);
let (resp, edit) = fw::curves_graph(ui, gr, &map[ch], ch + 1, false, false, gid, &t);
app.auto.add(&format!("{prefix}.graph"), gr, "Arbitrary Map");
register_points(app, &prefix, gr, &map[ch].0);
let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked());
+26 -6
View File
@@ -291,7 +291,8 @@ impl CurvePoints {
pub fn hit(&self, x: f32, y: f32, r: f32) -> Option<usize> {
self.0.iter().enumerate().map(|(i, p)| (i, (p[0] - x).hypot(p[1] - y))).filter(|(_, d)| *d <= r).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
}
/// The curve the effect renders (the same monotone spline), evaluated at `x`.
/// The monotone spline (`Curve::parse`) through the points, evaluated at `x`: the curve
/// Lumetri's RGB curves and Glow's arbitrary map render.
pub fn eval(&self, x: f32) -> f32 {
effectcraft_engine::effects::Curve::parse(&self.format()).map(|c| c.eval(x)).unwrap_or(x)
}
@@ -326,8 +327,18 @@ pub struct CurveEdit {
/// The Curves graph: grid, identity diagonal, the curve, its points. Click adds a point, drag
/// moves one, dragging a point off the graph (or Alt-clicking it) removes it. In pencil mode a
/// drag draws the curve freehand.
pub fn curves_graph(ui: &mut egui::Ui, rect: Rect, pts: &CurvePoints, channel: usize, pencil: bool, id: egui::Id, t: &Tokens) -> (egui::Response, CurveEdit) {
/// drag draws the curve freehand. `natural` draws the natural spline the Curves effect renders
/// (`Curve::parse_natural`) instead of the monotone one.
pub fn curves_graph(
ui: &mut egui::Ui,
rect: Rect,
pts: &CurvePoints,
channel: usize,
pencil: bool,
natural: bool,
id: egui::Id,
t: &Tokens,
) -> (egui::Response, CurveEdit) {
let resp = ui.interact(rect.expand(6.0), id, Sense::click_and_drag());
let p = ui.painter().with_clip_rect(rect.expand(8.0));
p.rect_filled(rect, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c));
@@ -349,7 +360,16 @@ pub fn curves_graph(ui: &mut egui::Ui, rect: Rect, pts: &CurvePoints, channel: u
};
let to_s = |x: f32, y: f32| pos2(rect.min.x + x * rect.width(), rect.max.y - y * rect.height());
let to_g = |s: Pos2| [((s.x - rect.min.x) / rect.width()), ((rect.max.y - s.y) / rect.height())];
let spline = effectcraft_engine::effects::Curve::parse(&pts.format());
let spline =
if natural { effectcraft_engine::effects::Curve::parse_natural(&pts.format()) } else { effectcraft_engine::effects::Curve::parse(&pts.format()) };
let seed = || {
(0..=64)
.map(|i| {
let x = i as f32 / 64.0;
spline.as_ref().map(|c| c.eval(x)).unwrap_or(x)
})
.collect::<Vec<f32>>()
};
let line: Vec<Pos2> = (0..=96)
.map(|i| {
let x = i as f32 / 96.0;
@@ -375,12 +395,12 @@ pub fn curves_graph(ui: &mut egui::Ui, rect: Rect, pts: &CurvePoints, channel: u
let alt = ui.input(|i| i.modifiers.alt);
if pencil {
if resp.drag_started() {
ui.data_mut(|d| d.insert_temp(pencil_id, (0..=64).map(|i| pts.eval(i as f32 / 64.0)).collect::<Vec<f32>>()));
ui.data_mut(|d| d.insert_temp(pencil_id, seed()));
}
if (resp.dragged() || resp.clicked())
&& let Some(pt) = resp.interact_pointer_pos()
{
let mut samples: Vec<f32> = ui.data(|d| d.get_temp(pencil_id)).unwrap_or_else(|| (0..=64).map(|i| pts.eval(i as f32 / 64.0)).collect());
let mut samples: Vec<f32> = ui.data(|d| d.get_temp(pencil_id)).unwrap_or_else(seed);
let g = to_g(pt);
let i = (g[0].clamp(0.0, 1.0) * 64.0).round() as usize;
samples[i.min(64)] = g[1].clamp(0.0, 1.0);
+6
View File
@@ -35,6 +35,7 @@ pub mod paint_panels;
pub mod panel_kit;
pub mod path_vr_panels;
pub mod precomp;
pub mod primitive_dialog;
pub mod project;
pub mod properties;
pub mod puppet_tool;
@@ -151,6 +152,11 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
effectcraft_engine::commands::time::display_time(session, comp, t)
}
/// A length (a Duration) formatted per project settings, with no start offset.
pub fn duration(session: &Session, comp: &Comp, len: Tick) -> String {
effectcraft_engine::commands::time::display_duration(session, comp, len)
}
/// Ctrl/Cmd-click on a current-time display toggles the project's Time Display Style between
/// Timecode and Frames, as in After Effects (through Project Settings: one undo step).
pub(crate) fn toggle_time_display(app: &mut EffectcraftApp, resp: &egui::Response) {
@@ -0,0 +1,125 @@
//! New 3D Primitive dialog (Layer ▸ New ▸ 3D Primitive…): the primitive's type, name and
//! dimensions. OK runs `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D;
//! the layer's Geometry Options edit it afterwards.
use std::collections::BTreeMap;
use crate::i18n::tr;
use effectcraft_engine::project::build::{Ids, primitive_geometry_options};
use effectcraft_engine::project::{ItemId, ParamUi, PrimitiveKind, Property};
use egui::{Color32, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
#[derive(Clone, Debug, Default)]
pub struct PrimitiveDraft {
pub kind: PrimitiveKind,
pub name: String,
/// Whether the name was typed; until then it follows the type.
pub named: bool,
/// Dimensions typed, by Geometry Options match id, kept when the type changes (a sphere's
/// radius carries over to a cone); the rest are the type's defaults.
pub dims: BTreeMap<String, f64>,
/// The project (its epoch) and comp the dialog was opened for.
pub target: Option<(u64, ItemId)>,
}
fn target(app: &EffectcraftApp) -> Option<(u64, ItemId)> {
Some((app.session.script_ui.project_epoch, app.session.active_comp_id()?))
}
/// The dimensions a primitive type has: its Geometry Options as the engine builds them.
fn dimensions(kind: PrimitiveKind) -> Vec<Property> {
let mut next = 0;
primitive_geometry_options(&mut Ids(&mut next), kind).props().cloned().collect()
}
/// Open the dialog for a new primitive in the active comp.
pub fn open(app: &mut EffectcraftApp) -> Result<(), String> {
let target = Some(target(app).ok_or("no composition is open")?);
let kind = PrimitiveKind::Cube;
app.dialog_state.primitive = PrimitiveDraft { kind, name: kind.label().to_string(), named: false, dims: BTreeMap::new(), target };
app.dialog = Some(Dialog::NewPrimitive);
Ok(())
}
pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut d = app.dialog_state.primitive.clone();
// A project opened or another comp made active since (by a script or the control channel):
// the primitive would land somewhere the dialog was not opened for.
if target(app) != d.target {
app.ui.status = "New 3D Primitive closed: the composition changed".into();
app.dialog = None;
return;
}
let (mut ok, mut close) = (false, false);
super::dialogs::modal(ctx, "New 3D Primitive", vec2(420.0, 300.0), t, |ui| {
egui::Grid::new("primitive-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
ui.label(tr("Type"));
let combo = egui::ComboBox::from_id_salt("primitive-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| {
for (i, k) in PrimitiveKind::ALL.into_iter().enumerate() {
let item = ui.selectable_value(&mut d.kind, k, tr(k.label()));
app.auto.add(&format!("dialog.primitive.typeItem.{i}"), item.rect, k.label());
}
});
app.auto.add_response("dialog.primitive.type", &combo.response, "Type");
if !d.named {
d.name = d.kind.label().to_string();
}
ui.end_row();
ui.label(tr("Name"));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0));
d.named |= r.changed();
app.auto.add_response("dialog.primitive.name", &r, "Name");
ui.end_row();
for p in dimensions(d.kind) {
let ParamUi::Slider { min, max, decimals, .. } = p.ui else { continue };
let mut v = d.dims.get(&p.match_id).copied().unwrap_or(p.value.as_f64());
ui.label(tr(&p.name));
// Counts (segments, rings) are whole numbers; sizes are pixels.
let suffix = if decimals == 0 { "" } else { " px" };
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut v).range(min..=max).max_decimals(decimals as usize).suffix(suffix)));
app.auto.add_response(&format!("dialog.primitive.{}", p.match_id), &r, &p.name);
if r.changed() {
d.dims.insert(p.match_id, v);
}
ui.end_row();
}
});
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
app.auto.add("dialog.primitive.ok", b.rect, "OK");
ok |= b.clicked();
let c = ui.button(tr("Cancel"));
app.auto.add("dialog.primitive.cancel", c.rect, "Cancel");
close |= c.clicked();
});
});
app.dialog_state.primitive = d.clone();
if ok || super::dialogs::ui_enter(ctx) {
let mut p = json!({"kind": d.kind.label().to_ascii_lowercase(), "name": d.name});
for dim in dimensions(d.kind) {
if let Some(v) = d.dims.get(&dim.match_id) {
p[dim.match_id.as_str()] = json!(v);
}
}
if let Err(e) = app.session.execute("layer.newPrimitive", p) {
app.ui.status = e.to_string();
}
close = true;
}
if close {
app.dialog = None;
}
}
/// Menu routing: New 3D Primitive without parameters opens the dialog. Returns true when it did.
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
if id != "layer.newPrimitive" || !params.as_object().is_none_or(|m| m.is_empty()) {
return Ok(false);
}
open(app).map(|_| true)
}
+9 -5
View File
@@ -2752,7 +2752,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| {
for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
ui.menu_button(cat, |ui| {
widgets::menu_scroll(ui, |ui| {
widgets::menu_scroll(ui, false, |ui| {
for (label, text) in items {
if ui.button(label).clicked() {
picked = Some(text);
@@ -3692,7 +3692,11 @@ fn layer_cells(
continue;
}
let cr = Rect::from_min_max(pos2(x + 2.0, r.min.y + 1.0), pos2(x + TIME_W - 2.0, r.max.y - 1.0));
let tc = crate::panels::timecode(&app.session, comp, Tick::from_seconds_f64(secs) - if kind == "duration" { comp.display_start } else { Tick(0) });
let tc = if kind == "duration" {
crate::panels::duration(&app.session, comp, Tick::from_seconds_f64(secs))
} else {
crate::panels::timecode(&app.session, comp, Tick::from_seconds_f64(secs))
};
let resp = ui.interact(cr, egui::Id::new(("tl-cell", kind, lid)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to change"));
lp.text(pos2(cr.min.x + 4.0, cy), Align2::LEFT_CENTER, &tc, Tokens::mono(11.0), if resp.hovered() || resp.dragged() { t.hot_text } else { t.timecode });
app.auto.add(&format!("timeline.layer.{lid}.{kind}"), cr, &tc);
@@ -4156,7 +4160,7 @@ pub(crate) fn layer_menu(
}
}
ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| {
widgets::menu_scroll(ui, |ui| {
widgets::menu_scroll(ui, false, |ui| {
for m in BlendMode::ALL {
item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()}));
if m.ends_group() {
@@ -4217,11 +4221,11 @@ fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut
}
}
cats.sort_by_key(|(c, _)| c.to_ascii_lowercase());
widgets::menu_scroll(ui, |ui| {
widgets::menu_scroll(ui, false, |ui| {
for (cat, mut fx) in cats {
fx.sort_by_key(|e| e.name.to_ascii_lowercase());
ui.menu_button(crate::i18n::effects::category(cat), |ui| {
widgets::menu_scroll(ui, |ui| {
widgets::menu_scroll(ui, false, |ui| {
for e in fx {
item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers}));
}
+1 -1
View File
@@ -1291,7 +1291,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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| {
crate::widgets::menu_scroll(ui, |ui| {
crate::widgets::menu_scroll(ui, false, |ui| {
for (id, name) in &hits {
if ui.button(name).clicked() {
context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]})));
+71 -2
View File
@@ -607,9 +607,78 @@ pub fn any_menu_open(ctx: &egui::Context) -> bool {
/// whole: egui sizes a new menu from a default 400 pt area, so without a minimum a longer one
/// (Window ▸ Workspace) got stuck at that height with its last entries scrolled out of view
/// (#191).
pub fn menu_scroll<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
///
/// While the bar is mid press-and-browse (`browsing`), resting the pointer against the menu's
/// bottom or top edge scrolls it from there — a crawl at first, then a run, as native menus do —
/// so a long list can be walked with no wheel. Without the hold the edges do nothing: the wheel
/// and the scroll bar are how a normally opened menu scrolls.
pub fn menu_scroll<R>(ui: &mut Ui, browsing: bool, add: impl FnOnce(&mut Ui) -> R) -> R {
let max_h = ui.ctx().content_rect().height() - 40.0;
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add).inner
let out = egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add);
edge_scroll(ui, &out, browsing);
out.inner
}
/// The edge scroll [`menu_scroll`] adds while browsing: with the pointer resting in the bottom
/// (top) band of an overflowing menu, its content crawls up (down) after a short pause, ramps to
/// a run and stops at the end — and stops at once when the pointer leaves the band or the hold.
///
/// This runs after `ScrollArea::show`, which laid the menu out for the offset it had, so the new
/// offset goes into the area's state and shows on the next frame: a frame of lag, invisible at
/// these speeds. egui's own scrolling (wheel, drag, scroll bar) is untouched.
fn edge_scroll<R>(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput<R>, browsing: bool) {
/// How far from the top / bottom edge the pointer starts the crawl (points).
const BAND: f32 = 18.0;
/// The pause before the crawl starts: a pointer merely grazing the edge must not jump.
const DELAY: f32 = 0.12;
/// Points per second: a crawl at first, reaching the run about three quarters of a second in.
const CRAWL: f32 = 45.0;
const RAMP: f32 = 700.0;
const RUN: f32 = 560.0;
let ctx = ui.ctx();
let over = out.content_size.y - out.inner_rect.height();
let dir = match (browsing, over > 0.0, ctx.input(|i| i.pointer.latest_pos())) {
(true, true, Some(p)) if out.inner_rect.contains(p) => {
if p.y >= out.inner_rect.max.y - BAND {
1
} else if p.y <= out.inner_rect.min.y + BAND {
-1
} else {
0
}
}
_ => 0,
};
// The clock the pause counts from, kept per menu: entering or leaving an edge band restarts
// it, so a later stay there crawls off again instead of jumping straight to the run.
let now = ctx.input(|i| i.time);
let key = out.id.with("menu-edge-scroll");
let (since, prev) = ctx.data(|d| d.get_temp::<(f64, i32)>(key)).unwrap_or((now, 0));
let since = if dir == prev {
since
} else {
ctx.data_mut(|d| d.insert_temp(key, (now, dir)));
now
};
if dir == 0 {
return;
}
if now - since < f64::from(DELAY) {
// A pointer resting still fires no events: keep the frames coming until the crawl runs.
ctx.request_repaint();
return;
}
let speed = (CRAWL + RAMP * ((now - since) as f32 - DELAY)).min(RUN);
let crawl = ctx.input(|i| i.unstable_dt).min(0.05) * speed * dir as f32;
let new = (out.state.offset.y + crawl).clamp(0.0, over);
if new != out.state.offset.y {
let mut state = out.state;
state.offset.y = new;
state.store(ctx, out.id);
// At the end of the list the crawl stands still and asks for no more frames.
ctx.request_repaint();
}
}
/// egui's menu style with no gap between the rows: the gap the app's item spacing left between
+1
View File
@@ -39,6 +39,7 @@ mod ui_locked_color;
mod ui_media_browser_cache;
mod ui_media_browser_copy_path;
mod ui_menus;
mod ui_new_primitive;
mod ui_object_matte;
mod ui_panel_focus;
mod ui_panel_windows;
@@ -778,6 +778,23 @@ fn effect_control_name_can_be_renamed_from_its_header() {
assert_eq!(current_name(&h), "Point Control");
}
// Regression test for #1497: double-clicking an effect parameter's name in Effect Controls did
// nothing; it should reveal that property in the Timeline, as After Effects does.
#[test]
fn double_clicking_an_effect_property_name_reveals_it_in_the_timeline() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app);
settle(&mut h);
let row = format!("timeline.prop.{}.name", x.point);
assert!(!ids(&h).contains(&row), "the effect's property is not in the Timeline before the double-click");
assert!(!h.state().ui.timeline.reveal_props.contains(&x.point));
let point = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center();
double_click(&mut h, point);
h.run_steps(4);
assert!(h.state().ui.timeline.reveal_props.contains(&x.point), "the property is marked revealed in the Timeline");
assert!(ids(&h).contains(&row), "the Timeline shows the property's row");
}
#[test]
fn effect_rename_escape_keeps_the_name() {
let (app, x) = app();
+128
View File
@@ -29,6 +29,25 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.run_steps(2);
}
/// Press the primary button at `p` and keep it down.
fn press(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.run_steps(2);
}
/// Move the pointer to `p` (press or no press, whichever is held).
fn move_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.run_steps(2);
}
/// Release the primary button at `p`.
fn release(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Press and release `key` (one frame each), then let the UI settle.
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
@@ -244,3 +263,112 @@ fn space_previews_after_opening_the_demo_from_home() {
key(&mut h, Key::Space);
assert!(h.state().playback.playing, "Space previews the demo (#1059)");
}
/// The titles open on the press, not the release, so the menus can be browsed with the button
/// still held down: moving onto an entry and releasing there chooses it (as in After Effects).
#[test]
fn a_menu_opens_on_press_and_browsing_releases_choose_an_entry() {
let mut h = harness();
let edit = rect(&h, "menu.Edit");
press(&mut h, edit.center());
assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu opens on the press, before any release");
let item = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect();
move_to(&mut h, item.center());
release(&mut h, item.center());
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the release over the entry chose it");
assert!(!shown(&h, "Quick Apply"), "and closed the menu");
}
/// While a menu is open, hovering another title switches to that menu (native menu bars).
#[test]
fn hovering_another_title_while_a_menu_is_open_switches_to_it() {
let mut h = harness();
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu is open");
let file = rect(&h, "menu.File");
move_to(&mut h, file.center());
assert!(shown(&h, "Open Project"), "hovering File opened the File menu");
assert!(!shown(&h, "Keyboard Shortcuts"), "and closed Edit");
}
/// The press on the title of the open menu closes it again, and the click's release — a plain
/// click on the title — leaves it closed.
#[test]
fn pressing_the_open_title_again_closes_its_menu() {
let mut h = harness();
let file = rect(&h, "menu.File");
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"), "the File menu is open");
click_at(&mut h, file.center());
assert!(!shown(&h, "Open Project"), "the second press closed it");
}
/// Press-and-hold browse (AE's gesture) reaches into a submenu: while the button is still held,
/// moving onto the submenu row opens it, and the release inside the submenu picks the entry.
#[test]
fn hold_browse_opens_a_submenu_and_the_release_inside_picks_an_entry() {
let mut h = harness();
// Applying an effect needs a selected layer, as in the app after clicking one in the timeline.
let first = {
let cid = h.state().session.active_comp_id().expect("the demo opens a comp");
let comp = h.state().session.project.comp(cid).expect("the demo comp exists");
comp.layers.iter().find(|l| l.effects().is_some()).expect("the demo comp has a layer that takes effects").id
};
h.state_mut().session.state.selected_layers = vec![first];
let effect = rect(&h, "menu.Effect");
press(&mut h, effect.center());
assert!(shown(&h, "Effect Controls"), "the Effect menu opens on the press");
// The categories come from the effect registry; the first alphabetically is "3D Channel".
let cat = h.query_by_label_contains("3D Channel").expect("the category row is shown").rect();
move_to(&mut h, cat.center());
assert!(shown(&h, "Cryptomatte"), "moving onto a category while still holding opened its submenu");
let entry = h.query_by_label_contains("Cryptomatte").expect("the submenu is open").rect();
move_to(&mut h, entry.center());
release(&mut h, entry.center());
assert!(!shown(&h, "Effect Controls"), "the release closed the menus");
assert!(h.state().session.state.last_effect.is_some(), "and the release chose the entry: it applied its effect");
}
/// The open menus close on a press outside them and when the window loses focus. The pointer
/// leaving the window — onto the OS title bar, whose clicks are never forwarded to egui — closes
/// nothing, however long it stays away: there is no click to close on, and a graze or a stay
/// must not drop the menu. The closing press itself is not swallowed: it reaches the widget under
/// it, as a click does in After Effects.
#[test]
fn outside_presses_and_focus_loss_close_the_menus() {
let mut h = harness();
let file = rect(&h, "menu.File");
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"));
// A press in the empty part of the bar, outside every title and menu.
click_at(&mut h, pos2(1100.0, 12.0));
assert!(!shown(&h, "Open Project"), "a press outside the menus closes them");
// The press reaches the header's workspace tab behind the open menu: the menu closes and the
// tab still acts (the menus don't swallow input).
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"));
let tab = rect(&h, "header.workspace.Review");
click_at(&mut h, tab.center());
assert_eq!(h.state().ui.workspace, "Review", "the press reached the tab behind the open menu");
assert!(!shown(&h, "Open Project"), "and closed the menu");
// Moving onto the OS title bar fires the cursor-left event — its clicks are never forwarded
// to egui — which egui reports as `PointerGone`. It must not close the menus, however long
// the pointer stays away.
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"));
h.input_mut().events.push(Event::PointerGone);
h.run_steps(1);
assert!(shown(&h, "Open Project"), "leaving the window alone doesn't drop the menus at once");
let now = h.ctx.input(|i| i.time);
h.input_mut().time = Some(now + 60.0);
h.run_steps(2);
assert!(shown(&h, "Open Project"), "staying away doesn't close them either");
h.input_mut().events.push(Event::PointerMoved(file.center()));
h.run_steps(2);
assert!(shown(&h, "Open Project"), "the pointer coming back keeps them open too");
// Losing focus (alt-tabbing away, or clicking another window).
h.input_mut().events.push(Event::WindowFocused(false));
h.run_steps(2);
assert!(!shown(&h, "Open Project"), "losing focus closes the menus");
}
+227
View File
@@ -0,0 +1,227 @@
//! Layer ▸ New ▸ 3D Primitive… (#1734): the dialog picks a type, name and dimensions and runs
//! `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D in the same step.
use effectcraft_engine::Session;
use effectcraft_engine::project::{Layer, LayerSource, PrimitiveKind, Renderer};
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.step();
h
}
/// An automation element's rect in the last frame drawn (a dialog settles its place in its
/// first frames).
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Option<[f32; 4]> {
let auto = &h.state().auto;
auto.elements.iter().chain(auto.previous.iter()).find(|e| e.id == id).map(|e| e.rect)
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let [x, y, w, hh] = rect(h, id).unwrap_or_else(|| panic!("{id} is registered"));
let p = egui::pos2(x + w / 2.0, y + hh / 2.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
}
h.run_steps(2);
}
/// Types `text` over a field's contents and leaves it with Tab.
fn type_into(h: &mut Harness<'_, EffectcraftApp>, id: &str, text: &str) {
click(h, id);
assert!(h.ctx.egui_wants_keyboard_input(), "{id} has focus");
key(h, Key::A, Modifiers::COMMAND);
h.input_mut().events.push(Event::Text(text.into()));
h.step();
key(h, Key::Tab, Modifiers::NONE);
}
/// Layer ▸ New ▸ 3D Primitive…, as the menu runs it.
fn open(h: &mut Harness<'_, EffectcraftApp>) {
let (path, entry) =
effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.label == "3D Primitive...").expect("Layer ▸ New lists 3D Primitive...");
assert_eq!(path, ["Layer", "New"]);
assert_eq!(entry.command, "layer.newPrimitive");
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, &entry.command, entry.params_or_empty()).unwrap();
h.run_steps(2);
assert_eq!(h.state().dialog, Some(Dialog::NewPrimitive));
}
fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: PrimitiveKind) {
click(h, "dialog.primitive.type");
let i = PrimitiveKind::ALL.iter().position(|k| *k == kind).unwrap();
click(h, &format!("dialog.primitive.typeItem.{i}"));
}
fn layers(h: &Harness<'_, EffectcraftApp>) -> Vec<Layer> {
h.state().session.active_comp().unwrap().layers.clone()
}
fn renderer(h: &Harness<'_, EffectcraftApp>) -> Renderer {
h.state().session.active_comp().unwrap().renderer
}
fn geometry(l: &Layer, id: &str) -> f64 {
l.props.prop(&format!("geometryOptions/{id}")).unwrap_or_else(|| panic!("{id}")).value.as_f64()
}
/// A torus named "Ring" with a 250 px radius, the rest left at their defaults.
fn create_ring(h: &mut Harness<'_, EffectcraftApp>) {
open(h);
choose(h, PrimitiveKind::Torus);
type_into(h, "dialog.primitive.name", "Ring");
type_into(h, "dialog.primitive.radius", "250");
click(h, "dialog.primitive.ok");
assert_eq!(h.state().dialog, None);
}
fn assert_ring(l: &Layer) {
assert_eq!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Torus });
assert_eq!(l.name, "Ring");
assert_eq!(geometry(l, "radius"), 250.0);
assert_eq!(geometry(l, "tubeRadius"), 50.0);
}
#[test]
fn creates_a_primitive_in_a_classic_3d_comp_in_one_undoable_step() {
let mut h = harness();
assert_eq!(renderer(&h), Renderer::Classic3D);
let steps = h.state().session.history.undo.len();
create_ring(&mut h);
let ls = layers(&h);
assert_eq!(ls.len(), 1);
assert_ring(&ls[0]);
assert_eq!(renderer(&h), Renderer::Advanced3D, "the comp switches to Advanced 3D");
assert_eq!(h.state().session.history.undo.len(), steps + 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(layers(&h).is_empty());
assert_eq!(renderer(&h), Renderer::Classic3D, "undo restores the renderer too");
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
assert_ring(&layers(&h)[0]);
assert_eq!(renderer(&h), Renderer::Advanced3D);
}
#[test]
fn cancel_creates_nothing() {
let mut h = harness();
let steps = h.state().session.history.undo.len();
open(&mut h);
choose(&mut h, PrimitiveKind::Sphere);
type_into(&mut h, "dialog.primitive.radius", "80");
click(&mut h, "dialog.primitive.cancel");
assert_eq!(h.state().dialog, None);
assert!(layers(&h).is_empty());
assert_eq!(renderer(&h), Renderer::Classic3D);
assert_eq!(h.state().session.history.undo.len(), steps);
}
#[test]
fn each_type_offers_the_dimensions_its_layer_has() {
let mut h = harness();
for kind in PrimitiveKind::ALL {
open(&mut h);
choose(&mut h, kind);
click(&mut h, "dialog.primitive.ok");
let l = layers(&h).into_iter().find(|l| l.source == LayerSource::Primitive { kind }).unwrap();
assert_eq!(l.name, kind.label(), "the name follows the type until typed");
let group = l.props.group("geometryOptions").unwrap();
let ids: Vec<&str> = group.props().map(|p| p.match_id.as_str()).collect();
open(&mut h);
choose(&mut h, kind);
let controls = ["type", "name", "ok", "cancel"];
let shown: Vec<&str> = (h.state().auto.elements.iter())
.filter_map(|e| e.id.strip_prefix("dialog.primitive."))
.filter(|id| !controls.contains(id) && !id.starts_with("typeItem."))
.collect();
assert_eq!(shown, ids, "{} dimensions", kind.label());
click(&mut h, "dialog.primitive.cancel");
}
}
#[test]
fn a_created_primitive_survives_save_and_reopen() {
let mut h = harness();
create_ring(&mut h);
let path = std::env::temp_dir().join(format!("ec-ui-new-primitive-{}.ecproj", std::process::id()));
h.state_mut().session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let mut o = Session::default();
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
let _ = std::fs::remove_file(&path);
let (_, comp) = o.project.comps().next().unwrap();
assert_eq!(comp.renderer, Renderer::Advanced3D);
assert_ring(&comp.layers[0]);
}
#[test]
fn untouched_dimensions_take_each_types_defaults_and_typed_ones_carry_over() {
let mut h = harness();
open(&mut h);
choose(&mut h, PrimitiveKind::Plane);
click(&mut h, "dialog.primitive.ok");
let plane = layers(&h).remove(0);
assert_eq!(geometry(&plane, "width"), 400.0, "a plane's default, not the cube's 300");
open(&mut h);
type_into(&mut h, "dialog.primitive.width", "120");
choose(&mut h, PrimitiveKind::Plane);
click(&mut h, "dialog.primitive.ok");
let plane = layers(&h).into_iter().find(|l| l.id != plane.id).unwrap();
assert_eq!(geometry(&plane, "width"), 120.0, "the typed width carries over");
assert_eq!(geometry(&plane, "height"), 400.0);
}
/// A dialog left open while something else changes the editing context closes without creating
/// anything rather than adding the primitive where it was not opened.
fn closes_when(change: impl FnOnce(&mut Session)) {
let mut h = harness();
let a = h.state().session.active_comp_id().unwrap();
open(&mut h);
choose(&mut h, PrimitiveKind::Torus);
change(&mut h.state_mut().session);
let b = h.state().session.active_comp_id().unwrap();
let steps = h.state().session.history.undo.len();
h.run_steps(2);
assert_eq!(h.state().dialog, None, "the dialog closes");
assert!(rect(&h, "dialog.primitive.ok").is_none());
let s = &h.state().session;
for c in [a, b] {
if let Some(c) = s.project.comp(c) {
assert!(c.layers.is_empty(), "nothing is created");
assert_eq!(c.renderer, Renderer::Classic3D);
}
}
assert_eq!(s.history.undo.len(), steps);
assert!(h.state().ui.status.contains("changed"), "{}", h.state().ui.status);
}
#[test]
fn another_comp_made_active_closes_the_dialog() {
closes_when(|s| {
s.execute("comp.new", json!({"name": "Other", "width": 640, "height": 360, "duration": 2})).unwrap();
});
}
#[test]
fn a_replaced_project_closes_the_dialog_even_when_the_comp_id_is_reused() {
closes_when(|s| {
let before = s.active_comp_id().unwrap();
s.execute("file.newProject", json!({})).unwrap();
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap();
assert_eq!(s.active_comp_id(), Some(before), "the new project reuses the comp's id");
});
}
+44
View File
@@ -187,3 +187,47 @@ fn double_clicking_an_effect_in_the_timeline_opens_effect_controls() {
assert_eq!(st.selected_props.iter().map(|(l, u)| (l.0, *u)).collect::<Vec<_>>(), vec![(red, uid)], "with the effect selected");
assert!(!h.state().ui.timeline.open_groups.contains(&uid), "the effect did not twirl open");
}
// Regression test for #354: the focused panel's blue outline was painted before the panel body,
// so the body's background and widgets painted over it. It must be drawn after every panel body.
#[test]
fn focused_panel_outline_is_painted_over_the_panel_bodies() {
fn flatten<'a>(shape: &'a egui::Shape, clip: egui::Rect, out: &mut Vec<(egui::Rect, &'a egui::epaint::RectShape)>) {
match shape {
egui::Shape::Rect(r) => out.push((clip, r)),
egui::Shape::Vec(v) => v.iter().for_each(|s| flatten(s, clip, out)),
_ => {}
}
}
let (mut h, _, _) = harness();
for panel in [PanelKind::Timeline, PanelKind::Project] {
h.state_mut().ui.focused = panel;
h.run_steps(3);
let focus = h.state().tokens.focus;
let mut rects = vec![];
h.output().shapes.iter().for_each(|c| flatten(&c.shape, c.clip_rect, &mut rects));
// The one-pixel, unfilled outline round the focused panel's frame.
let body = rect(&h, &format!("panel.{}", panel.id()));
let outlines: Vec<usize> = rects
.iter()
.enumerate()
.filter(|(_, (_, r))| r.fill.a() == 0 && r.stroke.color == focus && r.stroke.width == 1.0 && r.rect.contains_rect(body))
.map(|(i, _)| i)
.collect();
let Some(&at) = outlines.last() else { panic!("{panel:?}: no focus outline painted") };
let outline = rects[at].1.rect;
// Nothing painted after it reaches the one-pixel ring it draws, where a panel's
// background or rows would hide it.
let ring = [
egui::Rect::from_min_max(outline.min, egui::pos2(outline.right(), outline.top() + 1.0)),
egui::Rect::from_min_max(egui::pos2(outline.left(), outline.bottom() - 1.0), outline.max),
egui::Rect::from_min_max(outline.min, egui::pos2(outline.left() + 1.0, outline.bottom())),
egui::Rect::from_min_max(egui::pos2(outline.right() - 1.0, outline.top()), outline.max),
];
for (clip, r) in &rects[at + 1..] {
let painted = r.rect.intersect(*clip);
let over = ring.iter().any(|edge| edge.intersect(painted).area() > 0.0);
assert!(r.fill.a() == 0 || !over, "{panel:?}: a filled rect {:?} (fill {:?}) is painted over the focus outline {outline:?}", r.rect, r.fill);
}
}
}
+73
View File
@@ -40,6 +40,33 @@ fn wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) {
h.run_steps(20);
}
/// Press the primary button at `p` and keep it down (press-and-browse, as on a menu title).
fn hold(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.run_steps(2);
}
/// Move the pointer to `p`, keeping whatever button is held.
fn move_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.run_steps(2);
}
/// The open menu popup's rect (frame included), from egui's area memory: the only tall area up.
fn open_popup(h: &Harness<'_, EffectcraftApp>) -> Rect {
h.ctx
.memory(|m| {
m.areas()
.visible_layer_ids()
.into_iter()
.filter(|l| l.order != egui::Order::Background)
.filter_map(|l| m.area_rect(l.id))
.find(|r| r.height() > 100.0)
})
.unwrap_or_else(|| panic!("no menu popup is open"))
}
/// Edit is taller than a short window: it scrolls (with the wheel, and a scroll bar that always
/// shows) so its last entries can be reached and clicked, instead of running off the bottom of
/// the window (#269).
@@ -59,6 +86,52 @@ fn a_menu_taller_than_the_window_scrolls() {
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click");
}
/// With the title still held down (press-and-browse), resting the pointer against the bottom
/// edge of a menu too tall for the window scrolls it from there — a crawl, then a run, as native
/// menus do — so the long list can be walked with no wheel.
#[test]
fn holding_the_title_scrolls_the_menu_from_its_bottom_edge() {
let mut h = harness(1200.0, 520.0);
let edit = rect(&h, "menu.Edit");
hold(&mut h, edit.center());
let before = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu opens on the press").rect();
assert!(before.max.y > 520.0, "Edit is taller than the window ({before:?})");
let menu = open_popup(&h);
// Creeping down the edge: the 18 pt band above the menu's inner bottom lies at one of these
// positions, whatever the frame's margin (they are 6 pt apart).
let mut walked = false;
for step in 0..5 {
move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32));
h.run_steps(8);
let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect();
if now.min.y < before.min.y {
walked = true;
break;
}
}
assert!(walked, "resting on the bottom edge of the held-down menu scrolled its entries up");
}
/// Without the title held down — the menu opened by a plain click — the same edge rest scrolls
/// nothing: the wheel (above) is how a click-opened menu scrolls, the pointer alone must not
/// move it (a hover may align the row under it by a few points; a crawl there would be a bug).
#[test]
fn without_the_hold_the_edge_does_not_scroll_the_menu() {
let mut h = harness(1200.0, 520.0);
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
let before = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect();
assert!(before.max.y > 520.0, "Edit is taller than the window ({before:?})");
let menu = open_popup(&h);
for step in 0..5 {
move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32));
h.run_steps(8);
let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect();
let crawled = before.min.y - now.min.y;
assert!(crawled < 60.0, "step {step}: the edge scrolled the menu with no title held down ({before:?} -> {now:?})");
}
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
@@ -119,3 +119,17 @@ fn dragging_bar_ends_trims_in_and_out() {
let (i, o, s) = timing(&h, pre);
assert!((i - 3.0).abs() < 1e-6 && (o - 8.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out extended to ~8 s, not slipped: in {i} out {o} start {s}");
}
/// With Frames and Frame Count Start at 1, In moves to 00061 but the Duration column still counts
/// the layer's 120 frames (#1807).
#[test]
fn duration_column_counts_frames_from_any_frame_count() {
let (mut h, solid, _) = harness();
effectcraft_ui_egui::panels::timeline::set_column(&mut h.state_mut().ui.timeline, "in", true).unwrap();
effectcraft_ui_egui::panels::timeline::set_column(&mut h.state_mut().ui.timeline, "duration", true).unwrap();
h.state_mut().session.execute("file.projectSettings", json!({"timeDisplay": "frames", "frameStart": 1})).unwrap();
h.run_steps(3);
let cell = |h: &Harness<'_, EffectcraftApp>, kind: &str| h.state().auto.find(&format!("timeline.layer.{solid}.{kind}")).expect("cell").label.clone();
assert_eq!(cell(&h, "in"), "00061");
assert_eq!(cell(&h, "duration"), "00120");
}
@@ -637,3 +637,80 @@ fn timeline_context_menu_creates_shapes_and_masks_from_text() {
assert!(h.query_by_label("Create Shapes from Text").is_none(), "no text conversion for a solid");
assert!(h.query_by_label("Create Masks from Text").is_none(), "no text conversion for a solid");
}
// Regression test for #979 (#958): right-clicking a vector footage layer (SVG) in the Timeline
// or the Composition viewer offered no Create Shapes from Vector Layer; raster layers must not.
#[test]
fn layer_context_menus_offer_create_shapes_from_vector_layer_for_vector_footage() {
use effectcraft_engine::project::LayerSource;
let dir = std::env::temp_dir().join(format!("ec-ui-vector-menu-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let svg = dir.join("logo.svg");
std::fs::write(
&svg,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20"><rect width="40" height="20" fill="#ff0000"/></svg>"##,
)
.unwrap();
let png = dir.join("photo.png");
image::RgbaImage::from_pixel(16, 16, image::Rgba([0, 128, 255, 255])).save(&png).unwrap();
let mut s = effectcraft_host::session();
let item = s.execute("file.import", json!({"paths": [svg.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap();
let raster = s.execute("file.import", json!({"paths": [png.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name":"Vec","width":320,"height":180,"duration":4})).unwrap();
// The raster layer sits below the SVG layer, so the viewer click below lands on the SVG.
let photo = s.execute("layer.addItem", json!({"item": raster})).unwrap()["layer"].as_u64().unwrap();
let logo = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let plate = s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
let row = |h: &Harness<'_, EffectcraftApp>, id: u64| {
let r = rect(h, &format!("timeline.layer.{id}.row"));
pos2(r.min.x + 210.0, r.center().y)
};
let label = "Create Shapes from Vector Layer";
let shapes =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
// A raster-less solid does not offer the conversion.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.run_steps(3);
let at = row(&h, plate);
right_click(&mut h, at);
assert!(h.query_by_label(label).is_none(), "no vector conversion for a solid");
h.key_press(egui::Key::Escape);
h.run_steps(3);
// Nor does raster footage.
h.state_mut().session.execute("layer.select", json!({"layers": [photo]})).unwrap();
h.run_steps(3);
let at = row(&h, photo);
right_click(&mut h, at);
assert!(h.query_by_label(label).is_none(), "no vector conversion for raster footage");
h.key_press(egui::Key::Escape);
h.run_steps(3);
// The Timeline row of the SVG layer offers it, and it converts the layer.
h.state_mut().session.execute("layer.select", json!({"layers": [logo]})).unwrap();
h.run_steps(3);
let at = row(&h, logo);
right_click(&mut h, at);
h.get_by_label(label).click();
h.run_steps(3);
assert_eq!(shapes(&h), 1, "a shape layer was created from the SVG layer");
// The Composition viewer's menu offers it for the SVG layer under the pointer too.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": plate, "path": "transform/position", "value": [40.0, 40.0]})).unwrap();
// The solid stays selected: the menu must follow the layer under the pointer, not the selection.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
right_click(&mut h, comp.center());
assert!(h.query_by_label(label).is_some(), "the viewer's context menu offers the vector conversion");
let _ = std::fs::remove_dir_all(&dir);
}
+3 -2
View File
@@ -180,6 +180,7 @@ Short recipes; every step is one tool call.
| Animate | `add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"}` |
| Effect with settings | `list_effects {"filter":"glow"}` → `add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}}` → `set_property {"layer":2,"path":"effects/#1/intensity","value":2}` |
| Key a green screen | `add_effect {"layer":2,"effect":"Keylight (1.2)"}` → `execute_command effect.pickColor {"layer":2,"effect":1,"param":"screenColour","x":20,"y":20,"average":true}` (a screen pixel, layer space; samples the effect's input) → `set_property {"layer":2,"path":"effects/#1/screenMatte/clipBlack","value":10}` → `render_frame {"transparent":true}` |
| Key objects by Cryptomatte | `add_effect {"layer":2,"effect":"Cryptomatte"}` → `execute_command effect.pickCryptomatte {"layer":2,"effect":1,"x":120,"y":80}` (the ID under that layer pixel replaces the Selection; `"mode":"add"` / `"remove"` edit it; `effect.cryptomatteAt` with the same point only reports the name) → `render_frame` |
| Drive with an expression | `set_property {"layer":2,"path":"transform/rotation","expression":"time*90"}` |
| Inspect a layer | `get_layer {"layer":2,"flat":true}` (every property with its `path`, value, key count) |
| Check the result | `render_frame {"time":1.5,"max_side":640}`; in bridge mode `screenshot {"panel":"Timeline"}` |
@@ -323,8 +324,8 @@ build real projects through these interfaces; they are worked examples of everyt
### Effect positions
Effect points, Puppet pins (`puppet.addPin`, `puppet.recordPin`), Paint, Liquify and Roto Brush
strokes and `effect.pickColor` are in **effect space**: layer pixels from the top-left of the
layer's effect bounds. For footage, solids and precomps that is the layer's own pixel grid. Shape
strokes, `effect.pickColor` and `effect.pickCryptomatte` are in **effect space**: layer pixels
from the top-left of the layer's effect bounds. For footage, solids and precomps that is the layer's own pixel grid. Shape
and text layers have comp-sized bounds centred on their content's origin, so in a `w` × `h` comp
the point `[w / 2, h / 2]` is that origin (a new effect's default point). `prop.get` / `prop.set`
read and write these values as they are.
+16 -5
View File
@@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t
| Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump |
| On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect |
| 32-bit float, no clamping | 306 | Measured: registry flags |
| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) |
| **Rendered output matches After Effects** | **partly measured** | Some colour effects are checked against After Effects 2026 renders of our own test projects: Tint, Tritone, Black & White, Exposure, Levels, Curves, Luma Key, Extract, 4-Color Gradient and Fill within 1–3/255, Brightness & Contrast up to 6.2/255 (Color Correction, Generate and Keying rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) |
| Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder |
| 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes |
@@ -30,15 +30,15 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Audio | 13 | 0 | CPU by design (applied in the mixdown) |
| Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) |
| Channel | 13 | 13 | — |
| Color Correction | 40 | 40 | Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) close to After Effects 2026 ramps, not identical (up to 6.2/255 off; the test allows 7/255) (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
| Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed |
| Expression Controls | 9 | 9 | — |
| Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed |
| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed |
| Immersive Video | 12 | 12 | — |
| Keying | 12 | 12 | Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed |
| Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed |
| Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format |
| Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU |
| Obsolete | 9 | 9 | — |
| Obsolete | 9 | 9 | Luma Key matches After Effects 2026 on a grey ramp (hard cut, Tolerance only for Similar / Dissimilar) |
| Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) |
| Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) |
| Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) |
@@ -64,6 +64,17 @@ the GPU is performance, not parity.
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) |
| 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) |
| 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026; still up to 6.2/255 off, not the same pixels), CPU and GPU |
| 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) |
| 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) |
| 2026-10-19 | minor | Fill without a mask: the layer becomes the fill colour at its alpha × Opacity (was a mix with the original), measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | Curves: natural cubic spline through the points, clamped to 0..1 (was a monotone spline), measured against After Effects 2026 (CPU and GPU); Lumetri's curves and Glow's Arbitrary Map keep the monotone spline |
| 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) |
| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) |
| 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending |
| 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls |
| 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests |
+4 -2
View File
@@ -688,8 +688,10 @@ Fixes from measured reports:
step (undo and redo keep it) and keeps the comp's cached frames; projects saved before open
with Auto. View ▸ Resolution and the panel's popup run `view.res.full|half|third|quarter|auto`
and `view.res.custom {factor}`, which now work headless too; `comp.info` reports `resolution`
and `resolutionFactor` (#417). Not yet: the magnification is not saved per comp, and the
Composition Settings dialog has no Resolution popup.
and `resolutionFactor` (#417). Composition Settings ▸ Basic has the same Resolution menu, with
the frame size and memory per 8bpc frame at that resolution beside it; OK sets it as the popup
does, outside the undo step of the other settings (#2167). Not yet: the magnification is not
saved per comp.
### Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel
+9 -6
View File
@@ -427,15 +427,18 @@ fn footage_cmds(seq_first: &Path, clip: &Path, still: &Path) -> Cmds {
c
}
/// Run `cmds` with `effectcraft-cli --empty run … --save-as out`.
/// Run `cmds` with `effectcraft-cli --empty run --args-file … --save-as out`: a file, because the
/// `layers` scenario is longer than a Windows command line (32 767 characters).
fn build_project(bin: &Path, cmds: &Cmds, out: &Path) -> Result<(), String> {
let mut cmd = Command::new(bin);
cmd.arg("--empty").arg("run");
let mut lines = String::new();
for (id, params) in cmds {
cmd.arg(id).arg(params.to_string());
lines.push_str(&format!("{id}\n{params}\n"));
}
cmd.arg("--save-as").arg(out);
cli(&mut cmd)
let args = out.with_extension("args");
std::fs::write(&args, lines).map_err(|e| format!("{}: {e}", args.display()))?;
let r = cli(Command::new(bin).arg("--empty").arg("run").arg("--args-file").arg(&args).arg("--save-as").arg(out));
let _ = std::fs::remove_file(&args);
r
}
/// Run a CLI command quietly; on failure, the tail of its output is the error.