mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge remote-tracking branch 'origin/main' into jp/ja-dialogs
# Conflicts: # crates/ui-egui/src/i18n/ui.rs
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: ¶ms, 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
@@ -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];
|
||||
|
||||
@@ -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("e(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("e(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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
@@ -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)| {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -461,3 +461,5 @@ mod tests_fx_warp;
|
||||
mod tests_init;
|
||||
#[cfg(test)]
|
||||
mod tests_particles;
|
||||
#[cfg(test)]
|
||||
mod tests_wgsl_grammar;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -72,6 +72,7 @@ pub enum Dialog {
|
||||
ViewOptions,
|
||||
CameraSettings,
|
||||
LightSettings,
|
||||
NewPrimitive,
|
||||
KeyVelocity,
|
||||
KeyInterpolation,
|
||||
TimeStretch,
|
||||
|
||||
@@ -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
@@ -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, ¶ms)? {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// New 3D Primitive without parameters opens its dialog.
|
||||
if crate::panels::primitive_dialog::route(app, id, ¶ms)? {
|
||||
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, ¶ms)? {
|
||||
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)>) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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]})));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user