Merge pull request #2260 from aiongg/contrib-batch-3

Integration batch 3: 19 contributor PRs (interface, engine, effects, GPU, PDF and EPS import)
This commit is contained in:
aiongg
2026-10-23 22:08:01 -04:00
committed by GitHub
57 changed files with 2152 additions and 178 deletions
+23 -5
View File
@@ -371,15 +371,22 @@ fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfigu
// Iris Pro: the device is lost and the window never opens.
// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor {
label: Some("EffectCraft device"),
required_limits: device_limits(adapter.limits()),
..Default::default()
});
setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));
}
config
}
/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,
/// so memory the compositor frees on the shared device goes back to the driver.
fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {
eframe::wgpu::DeviceDescriptor {
label: Some("EffectCraft device"),
required_limits: device_limits(adapter),
memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,
..Default::default()
}
}
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
/// capped at 16384 px like the headless renderer's device.
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
@@ -539,6 +546,17 @@ mod tests {
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
}
/// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.
#[test]
fn the_window_device_favours_memory_usage() {
use eframe::wgpu::Limits;
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
let d = super::device_descriptor(big);
assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));
assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);
assert_eq!(d.label, Some("EffectCraft device"));
}
/// A positional .ecprojx is a project file, not an unsupported media import (#528).
#[test]
fn launch_classifies_xml_and_json_projects_as_projects() {
+3
View File
@@ -95,3 +95,6 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.ki2pixel]
display_name = "ki2pixel"
+30
View File
@@ -509,11 +509,24 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---------------------------------------------------------------------------------------------
// CC Radial Fast Blur
/// Transparent pixels CC Radial Fast Blur adds on every side of a `w` × `h` buffer with Center
/// at `c` (buffer pixels) and smear `s`: content `r` from Center streaks out to `r / (1 − s)`,
/// so the farthest corner reaches `d · s / (1 − s)` past itself (at most 4096, as the other
/// growing effects). Shared with the GPU path so both grow the same way.
pub fn radial_fast_pad(c: (f64, f64), w: u32, h: u32, s: f64) -> u32 {
let (w, h) = (f64::from(w), f64::from(h));
let far = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| (x - c.0).hypot(y - c.1)).fold(0.0, f64::max);
((far * s / (1.0 - s)).ceil() + 1.0).min(4096.0) as u32
}
fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5;
if s <= 0.0 {
return b;
}
if !ctx.adjustment {
b.pad(radial_fast_pad(b.to_px(ctx.params.v2("center")), b.img.width, b.img.height, s));
}
let c = b.to_px(ctx.params.v2("center"));
let mode = ctx.params.e("zoom");
let src = b.img.clone();
@@ -906,6 +919,23 @@ mod tests {
assert_eq!(circle.len(), 21);
}
#[test]
fn radial_fast_blur_streaks_past_the_layer_edge() {
// Center left of an opaque square: the streaks run right, beyond the layer rectangle (#2095).
let img = Image::filled(20, 20, [1.0, 1.0, 1.0, 1.0]);
let out = run("ec.blur.ccradialfast", &img, &[("center", Value::Vec2([-20.0, 10.0])), ("amount", num(80.0))]);
assert!(out.img.width > 20 && out.img.height > 20, "{}x{}", out.img.width, out.img.height);
let (o, d) = (out.offset[0] as i64, out.offset[1] as i64);
assert!(out.img.get(30 + o, 10 + d)[3] > 0.1, "{:?}", out.img.get(30 + o, 10 + d));
// Its streak samples only the square: still opaque, so the content keeps its place.
assert!((out.img.get(19 + o, 10 + d)[3] - 1.0).abs() < 1e-5, "{:?}", out.img.get(19 + o, 10 + d));
// An adjustment layer is comp-sized already; its buffer is not grown.
let s = find("ec.blur.ccradialfast").unwrap();
let params = Params { values: [("center".into(), Value::Vec2([-20.0, 10.0])), ("amount".into(), num(80.0))].into_iter().collect() };
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [20.0, 20.0], seed: 1, adjustment: true, env: Default::default() };
assert_eq!((s.render)(&ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img.width, 20);
}
#[test]
fn reduce_flicker_blurs_vertically_only() {
let mut img = Image::filled(8, 8, [0.0, 0.0, 0.0, 1.0]);
+24 -3
View File
@@ -49,12 +49,15 @@ fn set_channels(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Shift Channels' Take … From menu. After Effects' ten options come first, in its order, so a
/// project or script that sets 10 ("Full Off" there) turns the channel off here too; "Half On" is
/// an EffectCraft extra and sits last.
const SHIFT_ORDER: [Src; 11] =
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off];
const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"];
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off, Src::Half];
const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Full Off", "Half On"];
fn shift_channels(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(10)]);
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(SHIFT_ORDER.len() - 1)]);
route(&mut b, s);
b
}
@@ -548,6 +551,24 @@ mod tests {
assert!(close(out.get(7, 0), [0.25, 0.25, 0.45, 0.5]));
}
/// #1797: the Take … From menu keeps After Effects' order, where the tenth option (`Enum(9)`
/// here, 10 to a script) is Full Off, not Half On.
#[test]
fn shift_channels_menu_follows_after_effects_order() {
assert_eq!(SHIFT_NAMES[8..], ["Full On", "Full Off", "Half On"]);
// The sample's right-hand pixels are half transparent, so a straight 0.0 and 0.5 are
// stored as 0.0 and 0.25.
let off = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(9))], sample());
assert!(close(off.get(0, 0), [0.2, 0.0, 0.9, 1.0]), "{:?}", off.get(0, 0));
assert!(close(off.get(7, 0), [0.1, 0.0, 0.45, 0.5]), "{:?}", off.get(7, 0));
let half = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(10))], sample());
assert!(close(half.get(0, 0), [0.2, 0.5, 0.9, 1.0]), "{:?}", half.get(0, 0));
assert!(close(half.get(7, 0), [0.1, 0.25, 0.45, 0.5]), "{:?}", half.get(7, 0));
// A value past the last option stays on the last one instead of panicking.
let past = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(99))], sample());
assert_eq!(past.get(0, 0), half.get(0, 0));
}
#[test]
fn channel_combiner_hls_round_trip() {
let img = sample();
+106 -12
View File
@@ -403,13 +403,42 @@ fn crypto_matte(planes: &RankPlanes, sel: &[u32], i: usize) -> f32 {
.clamp(0.0, 1.0)
}
/// [`id_color`] of the last ID seen on each rank plane, per worker. Neighbouring pixels mostly
/// carry the same IDs, so this skips the hash and the HSL conversion for most of them. Keyed by the
/// ID's bits, so it returns exactly what [`id_color`] would.
///
/// Keep the per-pixel loop below as it is: with NaN coverage, which NaN payload ends up in a
/// channel depends on the order the compiler adds the ranks in, and a row-wise loop let it reorder
/// them (`crypto_aux_image_is_bit_exact_on_edge_values` catches that).
#[derive(Default)]
struct IdColorMemo(Vec<(u32, [f32; 3])>);
impl IdColorMemo {
#[inline]
fn get(&mut self, rank: usize, id: f32) -> [f32; 3] {
let bits = id.to_bits();
match self.0.get_mut(rank) {
Some(e) if e.0 == bits => e.1,
Some(e) => {
*e = (bits, id_color(id));
e.1
}
None => {
let c = id_color(id);
self.0.resize(rank + 1, (bits, c));
c
}
}
}
}
/// The coverage-weighted ID colours at aux pixel `i` (the spec's preview).
#[inline]
fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] {
fn crypto_colors(planes: &RankPlanes, i: usize, memo: &mut IdColorMemo) -> [f32; 3] {
let mut c = [0.0f32; 3];
for (id, cov) in planes {
for (rank, (id, cov)) in planes.iter().enumerate() {
let (Some(&id), Some(&cov)) = (id.get(i), cov.get(i)) else { continue };
let k = id_color(id);
let k = memo.get(rank, id);
for j in 0..3 {
c[j] += k[j] * cov;
}
@@ -421,10 +450,37 @@ fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] {
/// selection's coverage (A), computed in parallel with the rank planes resolved once. The GPU
/// effect uploads this image and samples it per layer pixel.
pub fn crypto_aux_image(a: &AuxChannels, layer: &str, sel: &[u32]) -> Image {
crypto_aux_image_with(a, layer, sel, CryptoAux::ColorsAndMatte)
}
/// What [`crypto_aux_image_with`] computes per aux pixel.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CryptoAux {
/// The coverage-weighted ID colours (RGB) and the selection's coverage (A).
ColorsAndMatte,
/// Only the selection's coverage (A); the RGB stays 0.
MatteOnly,
}
impl CryptoAux {
/// What the Cryptomatte effect reads for `display` and Matte Only: the ID colours are only
/// read by the Colors display (0) without Matte Only; Matte, Preview and Matte Only need the
/// matte alone.
pub fn for_display(display: u32, matte_only: bool) -> Self {
if display == 0 && !matte_only { Self::ColorsAndMatte } else { Self::MatteOnly }
}
}
/// [`crypto_aux_image`]; with [`CryptoAux::MatteOnly`] the RGB stays 0 and only the matte (A) is
/// computed, for the displays that never read the ID colours.
pub fn crypto_aux_image_with(a: &AuxChannels, layer: &str, sel: &[u32], what: CryptoAux) -> Image {
let planes = a.crypto_rank_planes(layer);
let mut img = Image::new(a.width, a.height);
img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let c = crypto_colors(&planes, i);
img.data.par_iter_mut().enumerate().for_each_init(IdColorMemo::default, |memo, (i, px)| {
let c = match what {
CryptoAux::ColorsAndMatte => crypto_colors(&planes, i, memo),
CryptoAux::MatteOnly => [0.0; 3],
};
*px = [c[0], c[1], c[2], crypto_matte(&planes, sel, i)];
});
img
@@ -444,9 +500,8 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
let display = ctx.params.e("display");
let matte_only = ctx.params.b("matteOnly");
let planes = a.crypto_rank_planes(&layer);
let src = b.img.clone();
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let m = idx[i].map_or(0.0, |k| crypto_matte(&planes, &sel, k));
b.img.data.par_iter_mut().zip(idx.par_iter()).for_each_init(IdColorMemo::default, |memo, (px, &k)| {
let m = k.map_or(0.0, |k| crypto_matte(&planes, &sel, k));
if matte_only {
*px = [m, m, m, m];
return;
@@ -454,15 +509,15 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
*px = match display {
// Colors: coverage-weighted ID colours (the spec's preview), selection brighter.
0 => {
let c = idx[i].map_or([0.0; 3], |k| crypto_colors(&planes, k));
let c = k.map_or([0.0; 3], |k| crypto_colors(&planes, k, memo));
let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m);
[c[0], c[1], c[2], 1.0]
}
// Matte: the layer keyed by the selection.
1 => src.data[i].map(|v| v * m),
// Matte: the layer keyed by the selection (each pixel only reads itself, so no copy).
1 => px.map(|v| v * m),
// Preview: the layer with the selection tinted.
_ => {
let s = src.data[i];
let s = *px;
[s[0] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[1] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[2] * (1.0 - 0.5 * m), s[3]]
}
};
@@ -822,6 +877,20 @@ mod tests {
a
}
/// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values
/// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits).
#[test]
fn id_color_memo_matches_id_color() {
let a = edge_aux(true);
let planes = a.crypto_rank_planes("CryptoObject");
let mut memo = IdColorMemo::default();
for i in 0..12 {
for (r, (id, _)) in planes.iter().enumerate() {
assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}");
}
}
}
fn edge_selection() -> Vec<u32> {
vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()]
}
@@ -843,6 +912,31 @@ mod tests {
}
}
/// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values.
#[test]
fn crypto_aux_image_without_colors_keeps_the_matte() {
let a = edge_aux(true);
let sel = edge_selection();
let full = crypto_aux_image(&a, "CryptoObject", &sel);
let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly);
for (f, m) in full.data.iter().zip(&matte.data) {
assert_eq!(f[3].to_bits(), m[3].to_bits());
assert_eq!(m[..3], [0.0; 3]);
}
}
/// Only the Colors display without Matte Only reads the ID colours.
#[test]
fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() {
assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte);
assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly);
for display in [1, 2, 3, u32::MAX] {
for matte_only in [false, true] {
assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}");
}
}
}
/// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match
/// the lookup by name bit for bit in every display mode, pixels outside it (no aux index)
/// get an empty matte and no ID colour.
+153 -28
View File
@@ -107,7 +107,14 @@ fn norm3(v: [f64; 3]) -> [f64; 3] {
[v[0] / l, v[1] / l, v[2] / l]
}
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32;
/// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance.
fn norm3f(v: [f32; 3]) -> [f32; 3] {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
[v[0] / l, v[1] / l, v[2] / l]
}
fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
@@ -115,14 +122,23 @@ fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
let soft = ctx.params.f("softness").max(0.0) * b.scale;
let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 0.99) as f32;
let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 1.0) as f32;
if cut >= 1.0 {
// Endpoint guard: at Cut Away 100 the gradient antialias must not leave a half-covered
// pixel where an opaque source reaches exactly 1.0.
b.img.data.fill([0.0; 4]);
return b;
}
let blob = layer_or_self(ctx, &b, "blobLayer", true, false);
let prop = src_at(ctx.params.e("property"));
let raw = Plane::from_image(&blob, |px| {
let (c, a) = unpremul(px);
pick(prop, c, a) * if matches!(prop, crate::util::Src::Alpha) { 1.0 } else { a }
});
let h = gauss_plane(&raw, soft, soft);
// Oracle (AE 26.2, 16 bpc): a step thresholded at Cut 25/75 with Softness 20 moves the
// 50% contour by 5.55 px. For a Gaussian that is 0.67449 sigma, so sigma = 0.42 Softness.
// The same probe shows the matte stays a ~1 px antialiased threshold, not a broad ramp.
let h = gauss_plane(&raw, soft * 0.42, soft * 0.42);
let intensity = ctx.params.f("lightIntensity") / 100.0;
let lc = ctx.params.color("lightColor");
let point = ctx.params.e("lightType") == 1;
@@ -135,37 +151,69 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
let rough = ctx.params.f("roughness").clamp(0.001, 1.0);
let metal = (ctx.params.f("metal") / 100.0) as f32;
let shininess = (1.0 / rough).min(500.0);
let depth = (soft * 0.5).max(1.0);
let src = b.img.clone();
let (w, hh) = (b.img.width as usize, b.img.height as usize);
let span = (w.max(hh) as f64).max(1.0);
// The source matte and the RGB refraction are separate in the oracle:
// - the matte edge of a self step moves 5/9/14/18 px at Softness 5/10/20/40. A normal
// with z = 1, a ~30 px depth at 1920 and a slope scale of 1.5 Softness reproduces that;
// - RGB follows a height-field refraction with z = height: on a linear ramp the measured
// horizontal offset is dx = -0.34 / h, independent of Softness.
let matte_slope = soft * 1.5;
let matte_depth = if soft > 0.05 { span / 64.0 } else { 0.0 };
let rgb_depth = span * 0.34 * b.scale;
let light_slope = (soft * 0.65).max(1.0);
let limit = span * 0.5;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (xi, yi) = (x as i64, y as i64);
let hv = h.get(x, y);
let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5;
let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5;
// Refraction-like displacement down the slope, then the blobby matte.
let sx = x as f64 + 0.5 - gx * depth * 2.0;
let sy = y as f64 + 0.5 - gy * depth * 2.0;
let s = src.sample_bilinear(sx, sy);
let (c, a) = unpremul(s);
let m = ((hv - cut) / ((1.0 - cut) * 0.5).max(1e-3)).clamp(0.0, 1.0);
let alpha = a.max(s[3]) * m;
let grad = (gx * gx + gy * gy).sqrt();
// One-pixel antialiased threshold. A flat field is either fully in or fully out.
let aa = (1.25 * grad).max(5e-4);
let m = if grad > 1e-9 {
(0.5 + (hv - cut) as f64 / aa).clamp(0.0, 1.0) as f32
} else if hv > cut {
1.0
} else {
0.0
};
if m <= 0.0 {
*px = [0.0; 4];
continue;
}
// Warp the source matte with the softness-scaled bevel normal (z = 1).
let na = norm3([-gx * matte_slope, -gy * matte_slope, 1.0]);
let sa = src.sample_bilinear(x as f64 + 0.5 + na[0] * matte_depth, y as f64 + 0.5 + na[1] * matte_depth);
let alpha = sa[3].max(0.0) * m;
if alpha <= 0.0 {
*px = [0.0; 4];
continue;
}
let n = norm3([-gx * depth, -gy * depth, 1.0]);
let l = if point { norm3([lpos.0 - x as f64, lpos.1 - y as f64, lheight / 100.0 * span]) } else { ldir };
let ndl = dot3(n, l).max(0.0);
let hv3 = norm3([l[0], l[1], l[2] + 1.0]);
let sp = dot3(n, hv3).max(0.0).powf(shininess) * specular;
// Refract the colour through the height field (z = height for this pass).
let hz = (hv as f64).max(1e-3);
let rx = (-rgb_depth * gx / hz).clamp(-limit, limit);
let ry = (-rgb_depth * gy / hz).clamp(-limit, limit);
let srgb = src.sample_bilinear(x as f64 + 0.5 + rx, y as f64 + 0.5 + ry);
let (c, _) = unpremul(srgb);
let (gx32, gy32) = (gx as f32, gy as f32);
let n = norm3f([-gx32 * light_slope as f32, -gy32 * light_slope as f32, 1.0]);
let l = if point {
norm3f([lpos.0 as f32 - (x as f32 + 0.5), lpos.1 as f32 - (y as f32 + 0.5), (lheight / 100.0 * span) as f32])
} else {
[ldir[0] as f32, ldir[1] as f32, ldir[2] as f32]
};
let ndl = dot3f(n, l).max(0.0);
let hv3 = norm3f([l[0], l[1], l[2] + 1.0]);
let sp = dot3f(n, hv3).max(0.0).powf(shininess as f32) * specular as f32;
let mut out = [0.0f32; 3];
for i in 0..3 {
let lit = (ambient + diffuse * ndl * intensity) as f32 * lc[i];
let lit = (ambient + diffuse * ndl as f64 * intensity) as f32 * lc[i];
let spec_col = (1.0 - metal) * lc[i] + metal * c[i] * lc[i];
out[i] = c[i] * lit + (sp * intensity) as f32 * spec_col;
out[i] = c[i] * lit + (sp as f64 * intensity) as f32 * spec_col;
}
*px = premul(out, alpha);
}
@@ -880,19 +928,19 @@ pub fn specs() -> Vec<EffectSpec> {
"CC Blobbylize",
vec![
p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer),
p("property", "Property", Value::Enum(3), popup(&crate::util::SRC_NAMES[..8])),
p("softness", "Softness", num(6.0), slider(0.0, 200.0, 0.0, 50.0, 1)),
p("cutAway", "Cut Away", num(0.0), pct()),
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])),
p("softness", "Softness", num(20.0), slider(0.0, 500.0, 0.0, 50.0, 1)),
p("cutAway", "Cut Away", num(25.0), pct()),
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])),
p("lightHeight", "Light Height", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point),
p("lightHeight", "Light Height", num(65.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point),
p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("diffuse", "Diffuse", num(70.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("specular", "Specular", num(40.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)),
p("ambient", "Ambient", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("diffuse", "Diffuse", num(25.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("specular", "Specular", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("roughness", "Roughness", num(0.025), slider(0.001, 1.0, 0.001, 1.0, 3)),
p("metal", "Metal", num(100.0), pct()),
],
cc_blobbylize,
@@ -1127,6 +1175,83 @@ mod tests {
}
}
#[test]
fn blobbylize_defaults_match_ae() {
let s = crate::find("ec.distort.ccblobbylize").unwrap();
let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone();
assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red");
assert_eq!(default("softness"), num(20.0));
assert_eq!(default("cutAway"), num(25.0));
assert_eq!(default("lightHeight"), num(65.0));
assert_eq!(default("lightPosition"), pt(0.5, 0.5));
assert_eq!(default("ambient"), num(75.0));
assert_eq!(default("diffuse"), num(25.0));
assert_eq!(default("specular"), num(100.0));
assert_eq!(default("roughness"), num(0.025));
assert_eq!(default("metal"), num(100.0));
}
#[test]
fn blobbylize_cut_away_thresholds_a_height_ramp() {
// AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge.
let im = crate::util::gen_image(1920, 8, |x, _| {
let h = x as f32 / 1920.0;
[h, h, h, 1.0]
});
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]);
assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent");
assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque");
let edge = o.img.get(480, 4)[3];
assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold");
let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count();
assert!(partial <= 2, "{partial} antialiased columns instead of one");
}
#[test]
fn blobbylize_cut_away_100_is_empty() {
// Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100.
let im = crate::util::gen_image(1920, 8, |x, _| {
let h = x as f32 / 1920.0;
[h, h, h, 1.0]
});
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels");
}
#[test]
fn blobbylize_cut_away_100_clears_an_opaque_edge() {
// An opaque source reaches h = 1 on the high side of an edge; the gradient antialias
// used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels.
let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] });
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge");
}
#[test]
fn blobbylize_softness_warps_a_step_matte_inward() {
// AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte
// edge at about x = 946, not at the source edge. The PR's matte-preserving version
// left it at 960; the effect must warp the source matte.
let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] });
let o = run(
"ec.distort.ccblobbylize",
&[
("property", Value::Enum(3)),
("softness", num(20.0)),
("cutAway", num(0.0)),
("ambient", num(100.0)),
("diffuse", num(0.0)),
("specular", num(0.0)),
("metal", num(0.0)),
],
im,
&[],
);
assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]);
assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge");
assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped");
}
/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
/// (200 × 100) layer bounds, after `id` with `set`.
fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
+2 -2
View File
@@ -91,7 +91,7 @@ pub use sim::particle_state;
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 blur2::{camera_lens_plain, camera_lens_spans, radial_fast_pad};
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};
@@ -111,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, aux_point, crypto_aux_image, id_color, selection_hashes};
pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, 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::{
+16
View File
@@ -499,6 +499,22 @@ mod tests {
assert_eq!(pr.ui, popup.ui);
}
/// #1797: Shift Channels' menu put Half On before Full Off; a project saved then keeps the
/// option it chose, by label.
#[test]
fn shift_channels_half_on_and_full_off_are_remapped_by_label() {
const OLD: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"];
for (saved, want) in [(9u32, 10u32), (10, 9), (1, 1)] {
let mut next = 1;
let (spec, mut g) = instance("ec.channel.shiftchannels", &mut next);
let pr = g.get_mut("takeGreenFrom").unwrap();
pr.ui = ParamUi::Popup { options: OLD.iter().map(|s| s.to_string()).collect() };
pr.value = Value::Enum(saved);
upgrade_instance(spec, &mut g, &mut Ids(&mut next), SIZE);
assert_eq!(g.get("takeGreenFrom").unwrap().value, Value::Enum(want), "saved {saved}");
}
}
/// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were
/// plain text; old projects get the Choose… file parameter, keeping the path.
#[test]
+63 -5
View File
@@ -35,22 +35,44 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
if !repeat && !ctx.adjustment {
b.pad((s * 3.0).ceil() as u32);
}
b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment);
let (sx, sy) = (s * kx, s * ky);
// Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939).
b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) {
effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, repeat || ctx.adjustment)
} else {
gaussian_blur(&b.img, sx, sy, repeat || ctx.adjustment)
};
b
}
fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
if r == 0 {
let radius = (ctx.params.f("radius") * b.scale).max(0.0);
let r = radius.floor() as usize;
let frac = (radius - r as f64) as f32;
// Fractional radius: blend the two neighbouring integer radii so the result changes continuously (#1939).
let frac = if frac < 1e-4 { 0.0 } else { frac };
if r == 0 && frac == 0.0 {
return b;
}
let it = ctx.params.f("iterations").round().clamp(1.0, 50.0) as usize;
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad((r * it) as u32 + 1);
let reach = if frac > 0.0 { r + 1 } else { r };
b.pad((reach * it) as u32 + 1);
}
let edge = repeat || ctx.adjustment;
let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge);
if frac == 0.0 {
b.img = lo;
return b;
}
let hi = effectcraft_raster::box_blur(&b.img, (r + 1) * kx as usize, (r + 1) * ky as usize, it, edge);
for ((l, h), o) in lo.data.iter().zip(hi.data.iter()).zip(b.img.data.iter_mut()) {
for c in 0..4 {
o[c] = l[c] + (h[c] - l[c]) * frac;
}
}
b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment);
b
}
@@ -738,6 +760,42 @@ mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
/// White left half, black right half (one vertical edge at x = 20).
fn edge() -> effectcraft_raster::Image {
let mut img = effectcraft_raster::Image::filled(40, 8, [0.0, 0.0, 0.0, 1.0]);
for y in 0..8 {
for x in 0..20 {
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
img
}
fn blurred_row(id: &str, param: &str, v: f64) -> Vec<f32> {
let out = run_fx(id, &[(param, num(v)), ("repeatEdge", Value::Bool(true))], edge(), 0.0, EffectEnv::default());
(16..24).map(|x| out.img.get(x, 4)[0]).collect()
}
#[test]
fn small_gaussian_blurriness_is_continuous() {
// #1939: 0.9 used to be identical to 0, and 1.2 / 1.6 / 2.0 identical to each other.
let row = |v| blurred_row("ec.blur.gaussian", "blurriness", v);
assert_ne!(row(0.0), row(0.9));
assert_ne!(row(0.9), row(1.2));
assert_ne!(row(1.2), row(1.6));
assert_ne!(row(1.6), row(2.0));
assert_ne!(row(2.0), row(2.4));
}
#[test]
fn fractional_box_blur_radius_is_continuous() {
let row = |v| blurred_row("ec.blur.fastbox", "radius", v);
assert_ne!(row(0.0), row(0.5));
assert_ne!(row(0.5), row(0.9));
assert_ne!(row(1.2), row(1.6));
assert_ne!(row(1.6), row(2.4));
}
/// A bright square on mid grey.
fn scene() -> effectcraft_raster::Image {
let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]);
+43 -11
View File
@@ -406,15 +406,20 @@ pub fn fractal_gpu(ctx: &EffectCtx, offset: [f64; 2], scale: f64, turbulent: boo
sub_rotation: fr.sub_rotation != 0.0,
norm,
octaves,
mode: match ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE) {
BLEND_NONE => None,
m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)),
},
mode: blend_mode(ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE)),
opacity: (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0),
blend: ctx.params.f("blend") as f32 / 100.0,
}
}
/// Resolve a Blending Mode index: None → `None`, unknown → Normal.
fn blend_mode(m: u32) -> Option<BlendMode> {
match m {
BLEND_NONE => None,
m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)),
}
}
/// Map a value by an [`OVERFLOWS`] option.
pub fn overflow(v: f32, mode: u32) -> f32 {
match mode {
@@ -431,14 +436,11 @@ pub fn overflow(v: f32, mode: u32) -> f32 {
/// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
/// its masks (#939).
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: Option<BlendMode>, blend_orig: f32) -> [f32; 4] {
let src = [val * opacity, val * opacity, val * opacity, opacity];
let out = match mode {
BLEND_NONE => src,
m => {
let bm = BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal);
blend_pixel(bm, orig, src, 0.5)
}
None => src,
Some(bm) => blend_pixel(bm, orig, src, 0.5),
};
let k = 1.0 - blend_orig;
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
@@ -447,7 +449,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
let fr = Fractal::from_params(ctx, &b, turbulent);
let opacity = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0);
let mode = ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE);
let mode = blend_mode(ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE));
let blend = ctx.params.f("blend") as f32 / 100.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
@@ -679,4 +681,34 @@ mod tests {
}
assert!(axis / diag < 1.15, "gradient along the axes / along the diagonals: {}", axis / diag);
}
/// The base composite, resolving the Blending Mode by name on every call, kept only to prove
/// the hoisted lookup changes nothing.
fn old_composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
let src = [val * opacity, val * opacity, val * opacity, opacity];
let out = match mode {
BLEND_NONE => src,
m => {
let bm = BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal);
blend_pixel(bm, orig, src, 0.5)
}
};
let k = 1.0 - blend_orig;
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
}
#[test]
fn resolved_blend_matches_per_pixel_lookup() {
for m in (0..BLEND_MODES.len() as u32).chain([24, 999]) {
for orig in [[0.2, 0.4, 0.6, 1.0], [0.9, 0.1, 0.5, 0.5], [0.0, 0.0, 0.0, 0.0]] {
for val in [0.0, 0.3, 0.75, 1.0] {
for (op, bl) in [(1.0, 0.0), (0.6, 0.25)] {
assert_eq!(composite(orig, val, op, blend_mode(m), bl), old_composite(orig, val, op, m, bl), "mode {m}");
}
}
}
}
assert_eq!(blend_mode(BLEND_NONE), None);
assert_eq!(blend_mode(999), Some(BlendMode::Normal));
}
}
+17 -1
View File
@@ -99,7 +99,13 @@ fn gaussian_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf {
if !ctx.adjustment {
b.pad((s * 3.0).ceil() as u32);
}
b.img = gaussian_blur(&b.img, s * kx, s * ky, ctx.adjustment);
let (sx, sy) = (s * kx, s * ky);
// Small sigmas use the sampled kernel, like Gaussian Blur (#1939).
b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) {
effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, ctx.adjustment)
} else {
gaussian_blur(&b.img, sx, sy, ctx.adjustment)
};
b
}
@@ -175,6 +181,16 @@ mod tests {
assert!(out.img.get(c as i64, c as i64)[3] < 0.5);
}
#[test]
fn gaussian_legacy_small_blurriness_blurs() {
// #1939: Blurriness under ~1.2 used to be a no-op.
let mut img = Image::new(15, 15);
img.set(7, 7, [1.0, 1.0, 1.0, 1.0]);
let out = run_fx("ec.obsolete.gaussianlegacy", &[("blurriness", num(0.9))], img, 0.0, EffectEnv::default());
let c = out.img.width / 2;
assert!(out.img.get(c as i64, c as i64)[3] < 0.9);
}
#[test]
fn moved_effects_are_obsolete() {
for id in ["ec.key.luma", "ec.key.spill", "ec.blur.reduceflicker"] {
+16
View File
@@ -1,6 +1,7 @@
//! Edit menu.
use effectcraft_color::Label;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -201,6 +202,21 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if l.track_matte.is_some_and(|m| ids.contains(&m.layer)) {
l.track_matte = None;
}
// A layer-reference property (an Environment light's Source, an effect's Clone Source,
// Back Layer…) must read as None once its layer is gone, in this same history step: a
// dead id renders as nothing while the UI shows "None", which means "fall back to the
// comp's Environment Layer" (#2153). Keys carry the same value kind as the property.
l.props.walk_mut(&mut |p| {
let dead = |v: &KV| matches!(v, KV::Layer(Some(id)) if ids.contains(&LayerId(*id)));
if dead(&p.value) {
p.value = KV::Layer(None);
}
for k in &mut p.keys {
if dead(&k.value) {
k.value = KV::Layer(None);
}
}
});
}
st.selected_layers.clear();
st.selected_props.clear();
+12 -5
View File
@@ -129,17 +129,22 @@ pub fn reload_items(s: &mut Session, ids: &[ItemId]) -> crate::Result<usize> {
/// writing it) is left as it was. Returns the number reloaded and the files that couldn't be read
/// (only without `undoable`).
fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usize, BTreeSet<String>)> {
if s.importer.is_none() {
let Some(importer) = s.importer.clone() else {
return Err(crate::EngineError::Other("media import is not available in this build".into()));
}
};
let mut updates = vec![];
let mut paths = BTreeSet::new();
let mut failed = BTreeSet::new();
for i in ids {
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
// Probed the way it imports by default, so a sequence is found as one. A path that
// is not followed is not read.
let probed = s.stored_paths.check(f).map_err(|(p, why)| crate::doc_path::refusal(&p, why)).and_then(|_| s.probe_footage(&f.path));
// A sequence is probed the way it imports by default, so its run is found again; any
// other item is its one file, not the run a numbered still sits in (#2074). A path
// that is not followed is not read.
let probed = s
.stored_paths
.check(f)
.map_err(|(p, why)| crate::doc_path::refusal(&p, why))
.and_then(|_| if f.kind == FootageKind::Sequence { s.probe_footage(&f.path) } else { importer.probe(&f.path) });
let nf = match probed {
Ok(p) => refreshed(s, f, p),
Err(_) if undoable => Footage { missing: true, ..f.clone() },
@@ -193,6 +198,8 @@ fn forget(s: &mut Session, paths: &BTreeSet<String>) {
s.footage.forget(p);
}
s.layer_cache.clear();
// Reloaded files may keep their footage settings but not their size and time.
s.refresh_disk_salt(true);
s.events.push(crate::Event::PurgeCaches);
}
+38 -3
View File
@@ -521,6 +521,8 @@ pub struct Session {
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
pub footage_scan: Option<footage_reload::Scan>,
/// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]).
pub disk_salt: Option<(u64, u64)>,
/// How many script-issued commands are running ([`Session::execute_for_script`]): while one
/// is, settings that guard what scripts may do can't change (see
/// [`prefs::Scripting::same_permissions`]).
@@ -618,6 +620,7 @@ impl Default for Session {
check_footage_on_open: false,
stored_paths: Default::default(),
footage_stamps: Default::default(),
disk_salt: None,
footage_scan: None,
script_calls: ScriptCalls::default(),
}
@@ -736,12 +739,42 @@ impl Session {
if Arc::ptr_eq(&self.project, &self.saved_project) {
self.saved_revision = self.revision;
}
if self.disk_cache.is_some() {
self.layer_cache.set_disk_salt(effectcraft_render::disk_cache::footage_salt(&self.project));
}
self.refresh_disk_salt(false);
self.events.push(Event::ProjectChanged { revision: self.revision });
}
/// Salt the disk cache's layer keys with the project's footage files
/// ([`effectcraft_render::disk_cache::footage_salt`]). Taking it reads the size and time of
/// every file, every frame of an image sequence included, so it is taken again only when the
/// footage items changed, or with `force` (a project opened, footage reloaded, the disk cache
/// attached), not at every edit: with a few large sequences that cost each keystroke in a
/// text field over half a second (#1926).
pub fn refresh_disk_salt(&mut self, force: bool) -> Option<u64> {
use std::hash::{Hash, Hasher};
if self.disk_cache.is_none() {
self.disk_salt = None;
return None;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
for (id, it) in &self.project.items {
if let effectcraft_project::ItemKind::Footage(f) = &it.kind {
id.hash(&mut h);
format!("{f:?}").hash(&mut h);
}
}
let footage = h.finish();
if let Some((seen, salt)) = self.disk_salt
&& seen == footage
&& !force
{
return Some(salt);
}
let salt = effectcraft_render::disk_cache::footage_salt(&self.project);
self.disk_salt = Some((footage, salt));
self.layer_cache.set_disk_salt(salt);
Some(salt)
}
/// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
/// valid ([`effectcraft_project::Comp::same_pixels`]).
@@ -1175,6 +1208,8 @@ impl Session {
}
self.layer_cache.clear();
self.project = Arc::new(p);
// Its files may have changed since they were last stamped.
self.disk_salt = None;
self.history = History::default();
self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() };
self.path = path;
+4 -2
View File
@@ -102,7 +102,7 @@ impl Session {
/// and attach it to the layer cache. Without a folder setting the platform cache folder is
/// used, but only by frontends with a settings store (headless sessions stay off disk).
pub fn configure_disk_cache(&mut self) {
use effectcraft_render::disk_cache::{DiskCache, default_folder, footage_salt};
use effectcraft_render::disk_cache::{DiskCache, default_folder};
let d = &self.prefs.disk;
let folder = if d.disk_cache_folder.trim().is_empty() {
(self.config.is_some() && !cfg!(target_arch = "wasm32")).then(default_folder)
@@ -136,7 +136,9 @@ impl Session {
}
},
};
self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project))));
self.disk_cache = Some(dc.clone());
let salt = self.refresh_disk_salt(true).unwrap_or_default();
self.layer_cache.set_disk(Some((dc.clone(), salt)));
// Roto Brush / Object Matte segmentations persist there too (held weakly: switching the
// disk cache off drops it).
let store: std::sync::Arc<dyn effectcraft_effects::roto::SegStore> = dc.clone();
+8 -2
View File
@@ -486,10 +486,10 @@ pub fn encode_thumb(w: u32, h: u32, rgba: &[u8]) -> String {
/// Decode a thumbnail to RGB bytes (THUMB_W×THUMB_H×3).
pub fn decode_thumb(text: &str) -> Option<Vec<u8>> {
let t = text.trim();
if t.len() != THUMB_W * THUMB_H * 6 {
if t.len() != THUMB_W * THUMB_H * 6 || !t.is_ascii() {
return None;
}
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect()
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(t.get(i..i + 2)?, 16).ok()).collect()
}
/// Render a project's main comp at its poster time into a thumbnail.
@@ -943,6 +943,12 @@ mod tests {
use super::*;
use crate::config::{DirConfig, MemoryConfig};
#[test]
fn non_ascii_thumbnail_is_rejected_without_panicking() {
let malformed = format!("aé{}", "0".repeat(THUMB_W * THUMB_H * 6 - 3));
assert!(decode_thumb(&malformed).is_none());
}
#[test]
fn every_builtin_builds_with_controls_and_renders() {
let s = Session::default();
+17
View File
@@ -131,6 +131,23 @@ fn environment_light_is_listed_and_takes_a_source_layer() {
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None);
}
/// Deleting the layer a Source points at leaves no dangling id: "None" means the comp's
/// Environment Layer, and a dead id rendered as nothing at all (#2153).
#[test]
fn deleting_a_source_layer_clears_the_reference() {
let mut s = session();
let sky = s.execute("layer.newSolid", json!({"width": 256, "height": 128, "name": "Sky"})).unwrap()["layer"].as_u64().unwrap();
let env = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/source", "value": sky})).unwrap();
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), Some(sky));
s.execute("edit.clear", json!({"layers": [sky]})).unwrap();
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "the reference goes with the layer");
// One history step with the delete, so undo brings both back.
s.undo();
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), Some(sky));
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some());
}
#[test]
fn three_d_switch_undo_redo() {
let mut s = session();
+45 -2
View File
@@ -8,14 +8,16 @@ use serde_json::{Value, json};
use crate::Session;
/// Probes every file as a 64×32 still (the media layer's single-file probe).
/// Probes every file as a still 64 + its byte length wide and 32 high (the media layer's
/// single-file probe).
struct Stills;
impl crate::Importer for Stills {
fn probe(&self, path: &str) -> Result<Footage, String> {
if !std::path::Path::new(path).is_file() {
return Err(format!("{path}: no such file"));
}
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
let width = 64 + std::fs::metadata(path).map_or(0, |m| m.len() as u32);
Ok(Footage { path: path.into(), kind: FootageKind::Still, width, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
}
}
@@ -196,6 +198,19 @@ fn replace_footage_and_reload_keep_sequences() {
assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5));
}
/// #2074: reloading a numbered still probed it as its run, so it took the first file's size.
#[test]
fn reloading_a_numbered_still_measures_its_own_file() {
let dir = folder(&RUN);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": false})).unwrap()["items"][0].clone();
std::fs::write(at(&dir, "shot_0002.png"), [0; 16]).unwrap();
s.execute("file.reloadFootage", json!({"items": [item]})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.width, f.sequence.len()), ("shot_0002.png", FootageKind::Still, 80, 0));
assert_eq!(f.path, at(&dir, "shot_0002.png"));
}
/// #756: numbered DPX and Cineon scans import as sequences, as in After Effects (`.cin` was not
/// an image to the Media Browser, so its runs came in one file at a time).
#[test]
@@ -207,3 +222,31 @@ fn dpx_and_cineon_runs_import_as_sequences() {
got.sort();
assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}");
}
/// #1926: an edit that leaves the footage alone doesn't stamp every frame of its image sequences
/// again for the disk cache's salt (with large sequences each keystroke in a text field took over
/// half a second); Reload Footage does, and so does new footage.
#[test]
fn edits_keep_the_disk_salt_and_reload_stamps_the_files_again() {
let dir = folder(&RUN);
let cache = std::env::temp_dir().join(format!("ec-seq-salt-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache);
let mut s = session();
s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": cache.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap();
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true, "addToComp": true})).unwrap();
let item = r["items"][0].clone();
let salt = s.refresh_disk_salt(false).expect("a disk cache");
// A frame rewritten in place: same footage settings, another size on disk.
std::fs::write(at(&dir, "shot_0004.png"), b"a new render of this frame").unwrap();
s.execute("layer.newText", json!({"text": "title"})).unwrap();
s.execute("layer.setText", json!({"text": "title 2"})).unwrap();
assert_eq!(s.refresh_disk_salt(false), Some(salt), "edits don't stamp the files again");
s.execute("file.reloadFootage", json!({"items": [item]})).unwrap();
let reloaded = s.refresh_disk_salt(false).unwrap();
assert_ne!(reloaded, salt, "Reload Footage stamps them again");
std::fs::write(at(&dir, "extra.png"), b"").unwrap();
s.execute("file.import", json!({"paths": [at(&dir, "extra.png")]})).unwrap();
assert_ne!(s.refresh_disk_salt(false), Some(reloaded), "new footage counts");
let _ = std::fs::remove_dir_all(&cache);
}
+7 -4
View File
@@ -462,6 +462,12 @@ pub(crate) fn buffer_init(device: &wgpu::Device, queue: &wgpu::Queue, label: Opt
buffer
}
/// The descriptor for a device the compositor owns: `MemoryUsage` so freed textures give their
/// allocator blocks back instead of a few long-lived objects pinning large ones.
pub(crate) fn device_descriptor(required_limits: wgpu::Limits) -> wgpu::DeviceDescriptor<'static> {
wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, memory_hints: wgpu::MemoryHints::MemoryUsage, ..Default::default() }
}
impl GpuContext {
/// [`buffer_init`] on this context's device.
pub(crate) fn buffer_init(&self, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer {
@@ -870,10 +876,7 @@ impl GpuContext {
max_buffer_size: limits.max_buffer_size,
..wgpu::Limits::default()
};
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
.await
.map_err(|e| match by_name {
let (device, queue) = adapter.request_device(&device_descriptor(required_limits)).await.map_err(|e| match by_name {
true => format!("no device on {} (picked by WGPU_ADAPTER_NAME): {e}", adapter.get_info().name),
false => format!("no device: {e}"),
})?;
+10 -1
View File
@@ -261,6 +261,10 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
return Some(b);
}
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
// Small sigmas use the CPU's sampled kernel (#1939).
if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) {
return None;
}
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad(e, (s * 3.0).ceil() as u32)?;
@@ -270,7 +274,12 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
}
fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
let radius = (ctx.params.f("radius") * b.scale).max(0.0);
// Fractional radii blend two integer radii on the CPU (#1939).
if radius - radius.floor() >= 1e-4 {
return None;
}
let r = radius.floor() as usize;
if r == 0 {
return Some(b);
}
+5 -1
View File
@@ -278,7 +278,11 @@ fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option
let sel = effectcraft_effects::selection_hashes(pr.s("selection"), manifest);
// Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A), on all
// cores with the rank planes resolved once.
let img = effectcraft_effects::crypto_aux_image(a, &layer, &sel);
// The ID colours are only read by the Colors display; Matte, Preview and Matte Only need the
// matte alone.
// `tests_fx_depth::cryptomatte_every_display_matches_the_cpu` guards this against the shader.
let what = effectcraft_effects::CryptoAux::for_display(pr.e("display"), pr.b("matteOnly"));
let img = effectcraft_effects::crypto_aux_image_with(a, &layer, &sel, what);
let tex = e.g.upload_image(&img)?;
let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]);
let mut p = op(7);
+4
View File
@@ -188,6 +188,10 @@ fn gaussian_legacy(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
2 => (0.0, 1.0),
_ => (1.0, 1.0),
};
// Small sigmas use the CPU's sampled kernel (#1939).
if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) {
return None;
}
if !ctx.adjustment {
b.pad(e, (s * 3.0).ceil() as u32)?;
}
+4 -1
View File
@@ -117,11 +117,14 @@ fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
run(e, "gen_radial_blur", &p, b, None, Some(&data))
}
fn radial_fast(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
fn radial_fast(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5;
if s <= 0.0 {
return Some(b);
}
if !ctx.adjustment {
b.pad(e, effectcraft_effects::radial_fast_pad(b.to_px(ctx.params.v2("center")), b.img.width, b.img.height, s))?;
}
let c = b.to_px(ctx.params.v2("center"));
let mut p = Params::default();
p.u[0][0] = ctx.params.e("zoom");
+5 -2
View File
@@ -438,8 +438,10 @@ fn refine(e: &mut Enc, ctx: &EffectCtx, b: GBuf, hard: bool) -> Option<GBuf> {
const SET_CHANNEL_LAYERS: [&str; 4] = ["sourceLayer1", "sourceLayer2", "sourceLayer3", "sourceLayer4"];
/// Shift Channels' Take … From menu, in the order of `effects`' `SHIFT_ORDER`: the two must agree,
/// or the source depends on whether the effect renders on the GPU.
const SHIFT_ORDER: [Src; 11] =
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off];
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off, Src::Half];
const MATTE_ORDER: [Src; 10] = [Src::Red, Src::Green, Src::Blue, Src::Alpha, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off];
@@ -527,7 +529,8 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.f[0] = [level, pr.f("softness").clamp(0.0, 100.0) as f32 / 100.0, 0.0, 0.0];
}
"ec.channel.shiftchannels" => {
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(10)]));
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"]
.map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(SHIFT_ORDER.len() - 1)]));
return route(e, b, s);
}
"ec.channel.removecolormatting" => {
+4 -2
View File
@@ -586,17 +586,19 @@ fn blobbylize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let soft = pr.f("softness").max(0.0) * b.scale;
let blob = layer_or_self(e, ctx, &b, "blobLayer")?;
let raw = plane(e, &blob, pr.e("property").min(10));
let h = gaussian_blur(e, &raw, soft, soft, true);
let h = gaussian_blur(e, &raw, soft * 0.42, soft * 0.42, true);
let lpos = b.to_px(pr.v2("lightPosition"));
let lheight = pr.f("lightHeight");
let (d, el) = (pr.f("lightDirection").to_radians(), (lheight / 100.0).clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2);
let ldir = [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()];
let span = (b.img.width.max(b.img.height) as f64).max(1.0);
let mut p = Params::default();
p.f[0] = f4([(soft * 0.5).max(1.0), (pr.f("cutAway") / 100.0).clamp(0.0, 0.99), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0]);
p.f[0] = f4([(pr.f("cutAway") / 100.0).clamp(0.0, 1.0), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0, soft * 1.5]);
p.f[1] = f4([pr.f("diffuse") / 100.0, pr.f("specular") / 100.0, (1.0 / pr.f("roughness").clamp(0.001, 1.0)).min(500.0), pr.f("metal") / 100.0]);
p.f[2] = f4([lpos.0, lpos.1, lheight / 100.0 * span, (pr.e("lightType") == 1) as u32 as f64]);
p.f[3] = f4([ldir[0], ldir[1], ldir[2], 0.0]);
p.f[4] = rgb(pr.color("lightColor"), 0.0);
p.f[5] = f4([if soft > 0.05 { span / 64.0 } else { 0.0 }, span * 0.34 * b.scale, (soft * 0.65).max(1.0), 1.25]);
p.f[6] = f4([span * 0.5, 0.0, 0.0, 0.0]);
run(e, "ftr_blob", &p, b, Some(&h), None)
}
+39 -14
View File
@@ -569,9 +569,11 @@ fn ftr_bender(@builtin(global_invocation_id) gid: vec3<u32>) {
}
// ---------------------------------------------------------------- CC Blobbylize (distort3::cc_blobbylize)
// aux = the blurred blob plane (x). f[0] = (depth, cut away, intensity, ambient);
// f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y, point light height,
// point light); f[3] = distant light direction; f[4] = light colour.
// aux = the Softness-blurred blob plane (x). f[0] = (cut away, intensity, ambient,
// matte slope); f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y,
// point light height, point); f[3] = distant light direction; f[4] = light colour;
// f[5] = (matte depth, RGB depth, light slope, matte antialias width);
// f[6].x = displacement clamp.
@compute @workgroup_size(16, 16)
fn ftr_blob(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -579,32 +581,55 @@ fn ftr_blob(@builtin(global_invocation_id) gid: vec3<u32>) {
if (!ftr_inside(p)) {
return;
}
let depth = P.f[0].x;
let cut = P.f[0].y;
let cut = P.f[0].x;
if (cut >= 1.0) {
// Endpoint guard: an opaque source can reach h = 1.0, where the gradient antialias
// would otherwise leave a half-covered pixel at Cut Away 100.
textureStore(out, p, vec4<f32>(0.0));
return;
}
let hv = textureLoad(aux, p, 0).x;
let g = ftr_grad(p);
let grad = length(g);
var m = 0.0;
if (grad > 1e-9) {
let aa = max(P.f[5].w * grad, 5e-4);
m = clamp(0.5 + (hv - cut) / aa, 0.0, 1.0);
} else if (hv > cut) {
m = 1.0;
}
if (m <= 0.0) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
let f = ftr_centre(p);
let s = sample_bilinear(src, f.x - g.x * depth * 2.0, f.y - g.y * depth * 2.0);
let c = ftr_unpremul(s);
let a = max(s.w, 0.0);
let m = clamp((hv - cut) / max((1.0 - cut) * 0.5, 1e-3), 0.0, 1.0);
let alpha = max(a, s.w) * m;
// Softness-scaled bevel warp of the source matte (normal z = 1).
let na = normalize(vec3<f32>(-g.x * P.f[0].w, -g.y * P.f[0].w, 1.0));
let sa = sample_bilinear(src, f.x + na.x * P.f[5].x, f.y + na.y * P.f[5].x);
let alpha = max(sa.w, 0.0) * m;
if (alpha <= 0.0) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
let n = normalize(vec3<f32>(-g.x * depth, -g.y * depth, 1.0));
// Height-field refraction of the colour (normal z = height), independent of Softness.
let hz = max(hv, 1e-3);
let d = -g * P.f[5].y / hz;
let lim = P.f[6].x;
let disp = vec2<f32>(clamp(d.x, -lim, lim), clamp(d.y, -lim, lim));
let srgb = sample_bilinear(src, f.x + disp.x, f.y + disp.y);
let c = ftr_unpremul(srgb);
let n = normalize(vec3<f32>(-g.x * P.f[5].z, -g.y * P.f[5].z, 1.0));
var l = P.f[3].xyz;
if (P.f[2].w != 0.0) {
l = normalize(vec3<f32>(P.f[2].x - f32(p.x), P.f[2].y - f32(p.y), P.f[2].z));
l = normalize(vec3<f32>(P.f[2].x - f.x, P.f[2].y - f.y, P.f[2].z));
}
let intensity = P.f[0].z;
let intensity = P.f[0].y;
let ndl = max(dot(n, l), 0.0);
let h3 = normalize(vec3<f32>(l.x, l.y, l.z + 1.0));
let sp = powz(max(dot(n, h3), 0.0), P.f[1].z) * P.f[1].y;
let lc = P.f[4].xyz;
let metal = P.f[1].w;
let lit = (P.f[0].w + P.f[1].x * ndl * intensity) * lc;
let lit = (P.f[0].z + P.f[1].x * ndl * intensity) * lc;
let spec_col = (1.0 - metal) * lc + metal * c * lc;
let o = c * lit + (sp * intensity) * spec_col;
textureStore(out, p, vec4<f32>(o * alpha, alpha));
+33 -4
View File
@@ -475,12 +475,16 @@ pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool)
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
let expect_empty = id == "ec.distort.ccblobbylize" && vals.iter().any(|(k, v)| *k == "cutAway" && matches!(v, Value::Scalar(x) if *x == 100.0));
for scale in [1.0, 0.5] {
let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32);
let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale };
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.25, layer_size: size, seed: 11, adjustment, env: Default::default() };
let cpu = (spec.render)(&ctx(), buf.clone());
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
if expect_empty {
assert!(out.img.data.iter().all(|p| p[3] == 0.0), "{id} {vals:?} adj {adjustment} scale {scale}: Cut Away 100 produced non-zero alpha");
}
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
let d = diff(&cpu.img, &out.img, 1e-3);
let allow = if boundary_flips_allowed() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 };
@@ -520,12 +524,27 @@ pub(crate) fn c(r: f64, g: f64, b: f64) -> Value {
Value::Color([r, g, b, 1.0])
}
#[test]
fn small_blurs_fall_back_to_the_cpu() {
// #1939: small Gaussian sigmas and fractional box radii render on the CPU kernel.
for (id, vals) in [("ec.blur.gaussian", [("blurriness", n(0.9))]), ("ec.blur.fastbox", [("radius", n(1.5))])] {
let mut s = Scene::new(BitDepth::Bpc32);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut l = s.footage(70, 44);
s.effect(&mut l, id, &vals);
s.push(l);
check(&format!("{id} {vals:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
#[test]
fn gpu_effects_match_cpu() {
effect_case("ec.blur.gaussian", &[("blurriness", n(9.0))]);
effect_case("ec.blur.gaussian", &[("blurriness", n(4.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]);
effect_case("ec.blur.fastbox", &[("radius", n(5.0)), ("iterations", n(2.0))]);
effect_case("ec.blur.fastbox", &[("radius", n(3.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]);
// Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939).
effect_case("ec.blur.gaussian", &[("blurriness", n(14.0))]);
effect_case("ec.blur.gaussian", &[("blurriness", n(12.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]);
effect_case("ec.blur.fastbox", &[("radius", n(6.0)), ("iterations", n(2.0))]);
effect_case("ec.blur.fastbox", &[("radius", n(4.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]);
effect_case("ec.blur.directional", &[("direction", n(30.0)), ("length", n(12.0))]);
effect_case("ec.stylize.glow", &[("threshold", n(40.0)), ("radius", n(14.0)), ("intensity", n(1.5))]);
effect_case("ec.stylize.glow", &[("colors", Value::Enum(1)), ("operation", Value::Enum(1)), ("colorA", c(1.0, 0.8, 0.1))]);
@@ -1146,3 +1165,13 @@ fn upload_cache_drops_textures_of_freed_buffers() {
let kept = g.ctx.upload_entries();
assert!(kept <= before + 1, "the upload cache kept {kept} textures of freed buffers (had {before})");
}
/// The device the compositor owns asks for `MemoryUsage` so freed textures give
/// their allocator blocks back, while its label and limits stay as they were.
#[test]
fn owned_devices_favour_memory_usage() {
let d = crate::context::device_descriptor(wgpu::Limits::default());
assert!(matches!(d.memory_hints, wgpu::MemoryHints::MemoryUsage));
assert_eq!(d.label, Some("effectcraft gpu"));
assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d);
}
+12
View File
@@ -251,6 +251,18 @@ fn ids_and_cryptomatte() {
case("ec.3d.cryptomatte", &[("selection", Value::Str("alpha".into())), ("matteOnly", on())]);
}
/// The GPU skips the ID colours for displays that do not read them
/// ([`effectcraft_effects::CryptoAux::for_display`]): every display × Matte Only must still match
/// the CPU effect, so a shader that starts reading RGB in another mode fails here.
#[test]
fn cryptomatte_every_display_matches_the_cpu() {
for display in 0..3 {
for matte_only in [false, true] {
case("ec.3d.cryptomatte", &[("selection", Value::Str("beta, delta".into())), ("display", e(display)), ("matteOnly", Value::Bool(matte_only))]);
}
}
}
#[test]
fn extractor() {
case("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("green", Value::Str("UV.U".into())), ("whitePoint", n(1000.0))]);
+3 -2
View File
@@ -46,6 +46,7 @@ fn bevels_and_legacy_gaussian() {
effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(2.0)), ("lightAngle", n(200.0)), ("lightColor", c(1.0, 0.8, 0.5))]);
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.1)), ("lightIntensity", n(0.5))]);
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.3)), ("lightAngle", n(130.0))]);
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(7.0))]);
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(5.0)), ("blurDimensions", e(2))]);
// Sigma 3 px and up at half scale too: smaller ones render on the CPU (#1939).
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(14.0))]);
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(12.0)), ("blurDimensions", e(2))]);
}
+2
View File
@@ -31,6 +31,8 @@ fn cc_radial_fast_blur() {
effect_case("ec.blur.ccradialfast", &[("amount", n(40.0))]);
effect_case("ec.blur.ccradialfast", &[("amount", n(70.0)), ("zoom", e(1)), ("center", pt(22.4, 16.1))]);
effect_case("ec.blur.ccradialfast", &[("amount", n(25.0)), ("zoom", e(2))]);
// Center outside the layer: the buffer grows so the streaks pass its edge (#2095).
effect_case("ec.blur.ccradialfast", &[("amount", n(80.0)), ("center", pt(-30.0, 20.0))]);
}
#[test]
+3
View File
@@ -155,6 +155,9 @@ fn refine_mattes() {
fn channel_effects() {
effect_case("ec.channel.setchannels", &[("setRedTo", e(2)), ("setGreenTo", e(4)), ("setBlueTo", e(5)), ("setAlphaTo", e(6))]);
effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(3)), ("takeGreenFrom", e(7)), ("takeAlphaFrom", e(4))]);
// #1797: the constant sources at the end of the menu, where the GPU's order could drift from
// the CPU's (Full On, Full Off, Half On).
effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(8)), ("takeGreenFrom", e(9)), ("takeBlueFrom", e(10))]);
effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.3, 0.5, 0.7))]);
effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.8, 0.2, 0.1)), ("clipHdr", off())]);
for op in [0, 2, 3, 4, 8, 10, 12] {
+1
View File
@@ -126,6 +126,7 @@ fn distort() {
"ec.distort.ccblobbylize",
&[("property", e(4)), ("softness", n(3.0)), ("cutAway", n(30.0)), ("lightType", e(1)), ("lightPosition", pt(20.0, 10.0))],
);
effect_case("ec.distort.ccblobbylize", &[("cutAway", n(100.0))]);
effect_case("ec.distort.ccblobbylize", &[("property", e(5)), ("metal", n(30.0)), ("roughness", n(0.2))]);
}
+26 -1
View File
@@ -327,7 +327,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
let a = v3(&keys[i.saturating_sub(1)].value);
let c = v3(&keys[(i + 1).min(keys.len() - 1)].value);
let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0];
([-tout[0], -tout[1], -tout[2]], tout)
// A side facing a key at the same position gets no tangent, so the segment between two
// identical keys has zero length and the layer holds still (#1675).
let p = v3(&keys[i].value);
let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] };
let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout };
(tin, tout)
}
/// Effective spatial tangents of key `i` (auto or stored).
@@ -928,6 +933,26 @@ mod tests {
assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3]));
}
#[test]
fn auto_spatial_holds_between_identical_keys() {
let path = [(0.0, [0.0, 540.0]), (0.6, [400.0, 540.0]), (4.4, [400.0, 540.0]), (5.0, [0.0, 540.0])];
for eased in [false, true] {
let keys: Vec<Keyframe> = path
.iter()
.map(|&(t, v)| {
let k = Keyframe::new(s(t), Value::Vec2(v));
if eased { k.eased() } else { k }
})
.collect();
assert_eq!(spatial_tangents(&keys, 1).1, [0.0; 3]);
assert_eq!(spatial_tangents(&keys, 2).0, [0.0; 3]);
for t in [0.7, 1.0, 2.5, 4.0, 4.3] {
let p = evaluate(&keys, s(t), true).unwrap().as_vec3();
assert!((p[0] - 400.0).abs() < 1e-6 && (p[1] - 540.0).abs() < 1e-6, "eased={eased} t={t} p={p:?}");
}
}
}
#[test]
fn set_key_replaces_same_time() {
let mut keys = vec![];
+11 -7
View File
@@ -263,8 +263,8 @@ fn parse_cmap(data: &[u8]) -> CMap {
}
Obj::Array(list) => {
for (k, s) in list.iter().enumerate() {
if let Obj::Str(s) = s {
m.unicode.insert(lo + k as u32, utf16(s));
if let (Obj::Str(s), Some(c)) = (s, lo.checked_add(k as u32)) {
m.unicode.insert(c, utf16(s));
}
}
}
@@ -401,13 +401,15 @@ impl Font {
match w.get(i + 1).map(|x| file.resolve(x)) {
Some(Obj::Array(list)) => {
for (k, v) in list.iter().enumerate() {
f.cid_widths.insert(first + k as u32, file.resolve(v).num().unwrap_or(0.0) / 1000.0);
if let Some(cid) = first.checked_add(k as u32) {
f.cid_widths.insert(cid, file.resolve(v).num().unwrap_or(0.0) / 1000.0);
}
}
i += 2;
}
Some(Obj::Num(last)) => {
let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0;
for c in first..=(*last as u32).min(first + 65535) {
for c in first..=(*last as u32).min(first.saturating_add(65535)) {
f.cid_widths.insert(c, wv);
}
i += 3;
@@ -432,13 +434,15 @@ impl Font {
Some(Obj::Array(list)) => {
let v: Vec<f64> = list.iter().filter_map(|x| file.resolve(x).num()).collect();
for (k, t) in v.as_chunks::<3>().0.iter().enumerate() {
f.cid_vmetrics.insert(first + k as u32, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0));
if let Some(cid) = first.checked_add(k as u32) {
f.cid_vmetrics.insert(cid, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0));
}
}
i += 2;
}
Some(Obj::Num(last)) => {
let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break };
for c in first..=(last.max(0.0) as u32).min(first + 65535) {
for c in first..=(last.max(0.0) as u32).min(first.saturating_add(65535)) {
f.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0));
}
i += 5;
@@ -609,7 +613,7 @@ impl Font {
if self.cid_ranges.is_empty() {
return code;
}
self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map(|(lo, _, c)| c + (code - lo)).unwrap_or(0)
self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).and_then(|(lo, _, c)| c.checked_add(code - lo)).unwrap_or(0)
}
/// Vertical metrics of a code in text space (before the font size): the vertical
+132 -5
View File
@@ -28,6 +28,8 @@ enum V {
Name(Rc<str>),
/// A literal name (`/name`).
Lit(Rc<str>),
/// A built-in operator, as `load` returns it for a name no dictionary defines.
Op(Rc<str>),
Str(Rc<RefCell<Vec<u8>>>),
Proc(Rc<Vec<V>>),
Array(Rc<RefCell<Vec<V>>>),
@@ -47,7 +49,7 @@ impl V {
}
fn key(&self) -> String {
match self {
V::Name(n) | V::Lit(n) => n.to_string(),
V::Name(n) | V::Lit(n) | V::Op(n) => n.to_string(),
V::Str(s) => String::from_utf8_lossy(&s.borrow()).into_owned(),
V::Num(n) => format!("{n}"),
V::Bool(b) => format!("{b}"),
@@ -441,6 +443,12 @@ impl Interp {
break;
}
}
V::Op(op) => {
r = self.spend(1).and_then(|()| self.builtin(op, src));
if r.is_err() {
break;
}
}
// Other items count against the budget as names do.
other => {
r = self.spend(1);
@@ -454,7 +462,21 @@ impl Interp {
self.calls -= 1;
r
}
V::Name(n) => self.exec_name(&n, src),
// An executable name can run another (`exec` of `/exec cvx`), so names count too.
V::Name(n) => {
if self.calls >= MAX_CALL_DEPTH {
self.b.skip("PostScript (recursion limit)");
return Err(Flow::Quit);
}
self.calls += 1;
let r = self.exec_name(&n, src);
self.calls -= 1;
r
}
V::Op(op) => {
self.spend(1)?;
self.builtin(&op, src)
}
other => {
self.push(other);
Ok(())
@@ -467,6 +489,12 @@ impl Interp {
if let Some(v) = self.lookup(n) {
return match v {
V::Proc(_) => self.exec_obj(v, src),
// An operator stored under another name (`/mo /moveto load def`, as Illustrator's
// EPS prolog does for every path operator) runs that operator. It pushed the
// operator's name instead, so such files drew nothing (#955).
V::Op(op) => self.builtin(&op, src),
// An executable name (`/y /x cvx def`) runs what that name is bound to.
V::Name(_) => self.exec_obj(v, src),
other => {
self.push(other);
Ok(())
@@ -963,7 +991,7 @@ impl Interp {
}
"load" => {
let k = self.pop().key();
let v = self.lookup(&k).unwrap_or(V::Name(k.into()));
let v = self.lookup(&k).unwrap_or(V::Op(k.into()));
self.push(v);
}
"undef" => {
@@ -1160,13 +1188,14 @@ impl Interp {
}
"xcheck" => {
let v = self.pop();
self.push(V::Bool(matches!(v, V::Proc(_) | V::Name(_))));
self.push(V::Bool(matches!(v, V::Proc(_) | V::Name(_) | V::Op(_))));
}
"type" => {
let t = match self.pop() {
V::Num(_) => "realtype",
V::Bool(_) => "booleantype",
V::Name(_) | V::Lit(_) => "nametype",
V::Op(_) => "operatortype",
V::Str(_) => "stringtype",
V::Proc(_) | V::Array(_) => "arraytype",
V::Dict(_) => "dicttype",
@@ -1203,6 +1232,98 @@ impl Interp {
}
}
"currentglobal" | "currentpacking" | "currentshared" => self.push(V::Bool(false)),
// Operators that return values must push them, or the rest of the program reads a
// shifted stack: Illustrator's EPS prolog queries the device this way and then drew
// nothing (#955). Values are a plain device's defaults (PostScript Language
// Reference, 3rd ed., chapter 8).
"currentflat" => self.push(V::Num(1.0)),
"currentscreen" => {
self.push(V::Num(60.0));
self.push(V::Num(45.0));
self.push(V::Proc(Rc::new(vec![])));
}
"currentblackgeneration" | "currentundercolorremoval" | "currenttransfer" => self.push(V::Proc(Rc::new(vec![]))),
"currentcolortransfer" => {
for _ in 0..4 {
self.push(V::Proc(Rc::new(vec![])));
}
}
"currentpagedevice" | "currenthalftone" | "currentsystemparams" | "currentuserparams" | "currentdevparams" => {
if n == "currentdevparams" {
self.pop();
}
self.push(V::Dict(new_dict()));
}
"setuserparams" | "setsystemparams" | "setcolortransfer" | "setblackgeneration" | "setundercolorremoval" => {
let k = if n == "setcolortransfer" { 4 } else { 1 };
for _ in 0..k {
self.pop();
}
}
// `file status` → false; `string status` → false (no such file).
"status" => {
self.pop();
self.push(V::Bool(false));
}
"dictstack" => {
self.pop();
let ds: Vec<V> = self.dicts.iter().map(|d| V::Dict(d.clone())).collect();
self.push(array(ds));
}
"maxlength" => {
let k = match self.pop() {
V::Dict(d) => d.borrow().len(),
_ => 0,
};
self.push(V::Num(k.saturating_add(16) as f64));
}
"internaldict" => {
self.pop();
let d = self.dicts[0].clone();
self.push(V::Dict(d));
}
"undefineresource" => {
self.pop();
self.pop();
}
// `[ … /Name pdfmark`: a Distiller annotation; other interpreters discard it.
"pdfmark" => {
while let Some(v) = self.st.pop() {
if matches!(v, V::Mark) {
break;
}
}
}
// `source [params] /Name filter`. A SubFileDecode of the current file with a count of
// 0 reads to its end-of-data string: skip that data (an embedded XMP packet, which
// otherwise ran as PostScript). Other filters read what their source reads.
"filter" => {
let name = self.pop().key();
let mut skipped = false;
let source = if name == "SubFileDecode" && !matches!(self.st.last(), Some(V::Dict(_))) {
let eod = match self.pop() {
V::Str(s) => s.borrow().clone(),
_ => vec![],
};
let count = self.popn();
let source = self.pop();
if matches!(source, V::File) && count == 0.0 && !eod.is_empty() {
src.pos = match find_from(src.d, &eod, src.pos) {
Some(p) => p.saturating_add(eod.len()),
None => src.d.len(),
};
skipped = true;
}
source
} else {
if matches!(self.st.last(), Some(V::Dict(_))) {
self.pop();
}
self.pop()
};
// Data already stepped over leaves nothing for `image` to skip.
self.push(if skipped { V::Null } else { source });
}
"vmstatus" => {
for _ in 0..3 {
self.push(V::Num(0.0));
@@ -1236,9 +1357,15 @@ impl Interp {
let a: Vec<f64> = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect();
self.gs.dash = (!a.is_empty() && a.iter().any(|v| *v > 0.0)).then_some((a, off));
}
"setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "setscreen" | "settransfer" => {
"setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "settransfer" => {
self.pop();
}
// frequency angle proc
"setscreen" => {
for _ in 0..3 {
self.pop();
}
}
"setgray" => {
let g = self.popn().clamp(0.0, 1.0);
self.gs.color = [g; 3];
+44
View File
@@ -1090,3 +1090,47 @@ fn retained_layers_preserve_artwork_beyond_the_bounds_depth_limit() {
assert_eq!(cropped.pixel_size(), (120, 80), "unknown bounds keep the full page");
assert_eq!(offset, [0.0, 0.0]);
}
#[test]
fn cid_width_entries_at_the_top_of_the_cid_range_do_not_overflow() {
// A first CID of u32::MAX (larger values saturate to it) used to overflow `first + k` and `first + 65535`:
// a panic in debug builds; in release builds the list wrapped round to CID 0 and the range was dropped.
let load = |entry: &str| {
let type0 = "<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H /DescendantFonts [13 0 R] >>";
let desc = format!("<< /Type /Font /Subtype /CIDFontType2 {entry} >>");
let file = object::File::parse(&page_pdf("", "/Font << /F1 10 0 R >>", vec![(10, type0.into(), None), (13, desc, None)])).unwrap();
let d = file.objects.get(&(10, 0)).and_then(object::Obj::dict).unwrap();
font::Font::load(&file, d, &mut vec![])
};
let max = u32::MAX;
// c [w1 w2 ...]
let f = load("/W [4294967295 [500 600 700]]");
assert_eq!((f.width(max), f.width(0)), (0.5, 1.0));
// cfirst clast w
assert_eq!(load("/W [4294967295 4294967295 400]").width(max), 0.4);
// c [w1y vx vy ...]
let f = load("/W2 [4294967295 [1 2 3 4 5 6]]");
assert_eq!((f.vmetrics(max), f.vmetrics(0)), ((0.001, 0.002, 0.003), (-1.0, 0.5, 0.88)));
// cfirst clast w1y vx vy
assert_eq!(load("/W2 [4294967295 4294967295 1 2 3]").vmetrics(max), (0.001, 0.002, 0.003));
}
#[test]
fn cmap_entries_at_the_top_of_the_code_range_do_not_overflow() {
// A bfrange array starting at code 0xFFFFFFFF and a cidrange mapping onto CID u32::MAX used to overflow
// `lo + k` and `c + (code - lo)`: a panic in debug builds, a wrap round to code / CID 0 in release builds.
let to_unicode = "beginbfrange <FFFFFFFF> <FFFFFFFF> [<0041> <0042>] endbfrange";
let encoding = "begincodespacerange <0000> <FFFF> endcodespacerange begincidrange <0000> <FFFF> 4294967295 endcidrange";
let objs: Objs = vec![
(10, "<< /Type /Font /Subtype /Type0 /Encoding 15 0 R /ToUnicode 14 0 R /DescendantFonts [13 0 R] >>".into(), None),
(13, "<< /Type /Font /Subtype /CIDFontType2 /W [0 [700 800] 4294967295 [500]] >>".into(), None),
(14, "<< >>".into(), Some(to_unicode.as_bytes().to_vec())),
(15, "<< /Type /CMap >>".into(), Some(encoding.as_bytes().to_vec())),
];
let file = object::File::parse(&page_pdf("", "/Font << /F1 10 0 R >>", objs)).unwrap();
let d = file.objects.get(&(10, 0)).and_then(object::Obj::dict).unwrap();
let f = font::Font::load(&file, d, &mut vec![]);
assert_eq!((f.unicode(0xFFFF_FFFF), f.unicode(0)), ("A".to_string(), String::new()));
// Code 0 is CID u32::MAX; code 2 would be past it, so it falls back to CID 0 like an unmapped code.
assert_eq!((f.width(0), f.width(2)), (0.5, 0.7));
}
+49
View File
@@ -25,6 +25,55 @@ fn ink_x(img: &effectcraft_raster::Image) -> (i64, i64) {
(x0, x1)
}
/// #955: EPS files that name their operators (`/mo /moveto load def`, as Illustrator's do),
/// query the device and embed metadata read through a SubFileDecode filter draw their artwork.
/// They imported as an empty layer.
#[test]
fn eps_with_aliased_operators_device_queries_and_embedded_data_draws() {
let square = "1 0 0 setrgbcolor 50 25 mo 150 25 li 150 75 li 50 75 li cp f\n";
let aliases = "/mo /moveto load def /li /lineto load def /cp /closepath load def /f /fill load def\n";
let doc = eps(format!("{aliases}{square}").as_bytes());
assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "aliased operators draw the square");
// Every operator that returns a value pushes it, so the stack stays aligned.
let queries = "mark currentflat currentscreen currentpagedevice currenthalftone currentblackgeneration \
currentundercolorremoval currentcolortransfer currentsystemparams counttomark 13 eq /ok exch def cleartomark\n";
let doc = eps(format!("{aliases}{queries}ok {{ {square} }} if\n").as_bytes());
assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "device queries leave 13 values");
// Data up to the end-of-data string isn't run: its unbalanced brace would swallow the rest.
let data = "currentfile 0 (%END-OF-DATA) /SubFileDecode filter pop\n<x> { ( not PostScript\n%END-OF-DATA\n";
let doc = eps(format!("{aliases}{data}{square}").as_bytes());
assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "drawing after the embedded data");
}
#[test]
fn eps_filter_on_a_string_source_does_not_skip_the_program() {
let square = "1 0 0 setrgbcolor 50 25 moveto 150 25 lineto 150 75 lineto 50 75 lineto closepath fill\n";
let img = "gsave 1 1 8 [1 0 0 1 0 0] (FF) /ASCIIHexDecode filter image grestore\n";
// Images aren't drawn yet, so this parses without `eps`'s nothing-skipped check.
let doc = parse(format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n{img}{square}").as_bytes()).unwrap();
assert_eq!(doc.skipped, ["image"]);
assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "drawing after an image read from a string");
}
/// A loaded operator runs that operator even after its name is redefined (the prolog idiom
/// `/_m /moveto load def /moveto {… _m} def`), and an executable name bound with `cvx` runs what
/// its name is bound to.
#[test]
fn eps_loaded_operators_and_executable_name_aliases() {
let saved = "/_m /moveto load def /moveto {_m} def 1 0 0 setrgbcolor 50 25 moveto 150 25 lineto 150 75 lineto 50 75 lineto closepath fill\n";
assert!(coverage(&render(&eps(saved.as_bytes())), 50, 25, 150, 75) > 4900.0, "a redefined moveto that calls the saved one");
let alias = "/sq {50 25 moveto 150 25 lineto 150 75 lineto 50 75 lineto closepath fill} def /y /sq cvx def 1 0 0 setrgbcolor y\n";
assert!(coverage(&render(&eps(alias.as_bytes())), 50, 25, 150, 75) > 4900.0, "an executable name bound to a procedure's name");
}
/// Each `exec` of the executable name `exec` runs the next one: the chain is cut off at the call
/// depth instead of overflowing the stack.
#[test]
fn eps_exec_of_executable_names_stops_at_the_call_depth() {
let doc = parse(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n/exec cvx 99999 {dup} repeat exec\n").unwrap();
assert!(doc.skipped.iter().any(|s| s.contains("recursion limit")), "{:?}", doc.skipped);
}
#[test]
fn eps_text_with_bundled_fonts_and_show_variants() {
// Not embedded: drawn with the bundled stand-in, like the same text in a PDF.
+329
View File
@@ -0,0 +1,329 @@
//! A cheap check that a project file will read back, without parsing it.
//!
//! `serde_json` writes a non-finite float as `null` and refuses to parse nesting deeper than 128
//! containers, so a project holding either would be saved to a file that can't be opened. Walking
//! the value once, writing nothing, finds both without building the project again.
use std::fmt;
use serde::ser::{
self, Serialize, SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, SerializeTuple, SerializeTupleStruct, SerializeTupleVariant,
Serializer,
};
/// serde_json refuses to parse the 128th nested array/object (its recursion limit is 128).
const MAX_DEPTH: u32 = 127;
const NOT_FINITE: &str = "a number that isn't finite (NaN or infinity)";
const TOO_DEEP: &str = "nesting deeper than 127 levels";
/// Why a value can't be written to a project file.
#[derive(Debug)]
pub(crate) struct Unstorable(String);
impl fmt::Display for Unstorable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Unstorable {}
impl ser::Error for Unstorable {
fn custom<T: fmt::Display>(msg: T) -> Self {
Unstorable(msg.to_string())
}
}
/// Walk `value` as `serde_json` would write it, producing no text and allocating only on error.
pub(crate) fn check<T: Serialize + ?Sized>(value: &T) -> Result<(), Unstorable> {
let mut walker = Walker { depth: 0 };
value.serialize(&mut walker)
}
/// The current nesting depth of the value being walked.
struct Walker {
depth: u32,
}
impl Walker {
/// Enter `levels` nested containers, refusing anything serde_json couldn't parse back.
fn enter(&mut self, levels: u32) -> Result<(), Unstorable> {
let depth = self.depth.checked_add(levels).ok_or_else(|| Unstorable(TOO_DEEP.into()))?;
if depth > MAX_DEPTH {
return Err(Unstorable(TOO_DEEP.into()));
}
self.depth = depth;
Ok(())
}
fn leave(&mut self, levels: u32) {
self.depth = self.depth.saturating_sub(levels);
}
}
/// One compound value being walked; leaving it when it ends keeps the depth exact.
struct Compound<'a> {
walker: &'a mut Walker,
levels: u32,
}
impl<'a> Compound<'a> {
fn new(walker: &'a mut Walker, levels: u32) -> Result<Self, Unstorable> {
walker.enter(levels)?;
Ok(Self { walker, levels })
}
fn value<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
value.serialize(&mut *self.walker)
}
fn finish(self) -> Result<(), Unstorable> {
self.walker.leave(self.levels);
Ok(())
}
}
impl SerializeSeq for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_element<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeTuple for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_element<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeTupleStruct for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_field<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeTupleVariant for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_field<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeMap for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_key<T: Serialize + ?Sized>(&mut self, key: &T) -> Result<(), Unstorable> {
self.value(key)
}
fn serialize_value<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeStruct for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_field<T: Serialize + ?Sized>(&mut self, _key: &'static str, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl SerializeStructVariant for Compound<'_> {
type Ok = ();
type Error = Unstorable;
fn serialize_field<T: Serialize + ?Sized>(&mut self, _key: &'static str, value: &T) -> Result<(), Unstorable> {
self.value(value)
}
fn end(self) -> Result<(), Unstorable> {
self.finish()
}
}
impl<'a> Serializer for &'a mut Walker {
type Ok = ();
type Error = Unstorable;
type SerializeSeq = Compound<'a>;
type SerializeTuple = Compound<'a>;
type SerializeTupleStruct = Compound<'a>;
type SerializeTupleVariant = Compound<'a>;
type SerializeMap = Compound<'a>;
type SerializeStruct = Compound<'a>;
type SerializeStructVariant = Compound<'a>;
fn serialize_bool(self, _v: bool) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i8(self, _v: i8) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i16(self, _v: i16) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i32(self, _v: i32) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i64(self, _v: i64) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_i128(self, _v: i128) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u8(self, _v: u8) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u16(self, _v: u16) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u32(self, _v: u32) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u64(self, _v: u64) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_u128(self, _v: u128) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_f32(self, v: f32) -> Result<(), Unstorable> {
if v.is_finite() { Ok(()) } else { Err(Unstorable(NOT_FINITE.into())) }
}
fn serialize_f64(self, v: f64) -> Result<(), Unstorable> {
if v.is_finite() { Ok(()) } else { Err(Unstorable(NOT_FINITE.into())) }
}
fn serialize_char(self, _v: char) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_str(self, _v: &str) -> Result<(), Unstorable> {
Ok(())
}
/// serde_json writes bytes as an array of numbers: one level of nesting.
fn serialize_bytes(self, _v: &[u8]) -> Result<(), Unstorable> {
Compound::new(self, 1)?.finish()
}
/// Writes text for a `Display` value; it is a scalar, not a container.
fn collect_str<T: fmt::Display + ?Sized>(self, _v: &T) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_none(self) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_some<T: Serialize + ?Sized>(self, value: &T) -> Result<(), Unstorable> {
value.serialize(&mut *self)
}
fn serialize_unit(self) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_unit_struct(self, _name: &'static str) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_unit_variant(self, _name: &'static str, _index: u32, _variant: &'static str) -> Result<(), Unstorable> {
Ok(())
}
fn serialize_newtype_struct<T: Serialize + ?Sized>(self, _name: &'static str, value: &T) -> Result<(), Unstorable> {
value.serialize(self)
}
fn serialize_newtype_variant<T: Serialize + ?Sized>(self, _name: &'static str, _index: u32, _variant: &'static str, value: &T) -> Result<(), Unstorable> {
let mut compound = Compound::new(self, 1)?;
compound.value(value)?;
compound.finish()
}
fn serialize_seq(self, _len: Option<usize>) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_tuple(self, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_tuple_struct(self, _name: &'static str, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_tuple_variant(self, _name: &'static str, _index: u32, _variant: &'static str, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 2)
}
fn serialize_map(self, _len: Option<usize>) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_struct(self, _name: &'static str, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 1)
}
fn serialize_struct_variant(self, _name: &'static str, _index: u32, _variant: &'static str, _len: usize) -> Result<Compound<'a>, Unstorable> {
Compound::new(self, 2)
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::check;
#[test]
fn refuses_non_finite_numbers_anywhere() {
assert!(check(&1.5f64).is_ok());
assert!(check(&vec![Some(1.0f64), None]).is_ok());
assert!(check(&BTreeMap::from([("a".to_string(), 2.0f32)])).is_ok(), "finite floats in a map");
assert!(check(&f64::NAN).is_err());
assert!(check(&f64::INFINITY).is_err());
assert!(check(&vec![Some(f64::NEG_INFINITY)]).is_err(), "inside an option in a list");
assert!(check(&BTreeMap::from([("a".to_string(), f32::NAN)])).is_err(), "as a map value");
}
#[test]
fn depth_limit_matches_serde_json() {
for n in [127, 128] {
let mut v = serde_json::Value::Null;
for _ in 0..n {
v = serde_json::Value::Array(vec![v]);
}
let text = serde_json::to_string(&v).unwrap();
let reads_back = serde_json::from_str::<serde_json::Value>(&text).is_ok();
assert_eq!(check(&v).is_ok(), reads_back, "depth {n} agrees with serde_json");
assert_eq!(check(&v).is_ok(), n == 127, "depth {n}");
}
}
/// `n` nested lists around bytes, which serde_json writes as one more array.
struct BytesUnder(u32);
impl serde::Serialize for BytesUnder {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let Some(inner) = self.0.checked_sub(1) else {
return s.serialize_bytes(&[1]);
};
let mut seq = s.serialize_seq(Some(1))?;
seq.serialize_element(&BytesUnder(inner))?;
seq.end()
}
}
#[test]
fn bytes_count_as_one_level() {
for n in [126, 127] {
let text = serde_json::to_string(&BytesUnder(n)).unwrap();
let reads_back = serde_json::from_str::<serde_json::Value>(&text).is_ok();
assert_eq!(check(&BytesUnder(n)).is_ok(), reads_back, "depth {n} agrees with serde_json");
assert_eq!(check(&BytesUnder(n)).is_ok(), n == 126, "depth {n}");
}
}
}
+15 -4
View File
@@ -8,6 +8,7 @@
pub mod build;
pub mod essential;
mod file_check;
pub mod limits;
pub mod props;
pub mod render_queue;
@@ -1540,13 +1541,20 @@ impl Project {
&& self.render_prefs == other.render_prefs
}
/// The project file's text: the project with the version that wrote it (`savedBy`), checked
/// to read back, so a save never writes a file that can't be opened (JSON has no NaN or
/// infinity: such a value would be written as `null`).
/// to read back, so a save never writes a file that can't be opened. Values JSON can't hold
/// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every
/// save; debug builds also read the text back.
pub fn to_file_json(&self) -> Result<String, ProjectError> {
self.to_file_json_with_view(&SavedView::default())
}
/// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]).
pub fn to_file_json_with_view(&self, view: &SavedView) -> Result<String, ProjectError> {
if self.schema > SCHEMA_VERSION {
return Err(ProjectError::Invalid(format!(
"the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))",
self.schema
)));
}
#[derive(Serialize)]
struct File<'a> {
#[serde(rename = "savedBy")]
@@ -1556,8 +1564,11 @@ impl Project {
#[serde(flatten)]
project: &'a Project,
}
let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, view, project: self })
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?;
let file = File { saved_by: APP_VERSION, view, project: self };
file_check::check(&file)
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?;
let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?;
#[cfg(debug_assertions)]
Project::from_json(&text)
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?;
Ok(text)
+23
View File
@@ -166,10 +166,33 @@ fn project_files_name_their_version_and_always_read_back() {
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::NAN);
let e = p.to_file_json().unwrap_err().to_string();
assert!(e.contains("can't be saved"), "{e}");
// Infinity too, in either profile: the check doesn't rely on reading the file back.
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::INFINITY);
let e = p.to_file_json().unwrap_err().to_string();
assert!(e.contains("can't be saved"), "{e}");
assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9"));
assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1"));
}
#[test]
fn project_files_refuse_newer_schemas() {
let p = Project { schema: crate::SCHEMA_VERSION + 1, ..Project::default() };
let expected = format!(
"the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({}))",
p.schema,
crate::SCHEMA_VERSION
);
assert_eq!(p.to_file_json().unwrap_err().to_string(), expected);
assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected);
}
#[test]
fn saved_files_reopen_in_pretty_and_compact_form() {
let (p, _cid) = project_with_layer();
assert_eq!(Project::from_json(&p.to_file_json().unwrap()).unwrap(), p, "the pretty project file");
assert_eq!(Project::from_json(&p.to_json_compact()).unwrap(), p, "compact JSON");
}
#[test]
fn project_files_remember_the_open_comps() {
let (p, cid) = project_with_layer();
+57 -2
View File
@@ -1,5 +1,5 @@
//! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional
//! and radial (spin/zoom) blurs.
//! Blurs: separable box passes (exact running sums) approximating Gaussians, a sampled Gaussian
//! kernel for small sigmas, plus directional and radial (spin/zoom) blurs.
//!
//! Box passes run straight on row-major pixels: horizontal passes per row, vertical passes over
//! blocks of rows with a per-column running sum (no transposes), ping-ponging between two
@@ -157,6 +157,50 @@ pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> I
box_passes(img, &rx, &ry, repeat)
}
/// Below this sigma (pixels) three integer-radius box passes are too coarse (sigma under ~0.6 is
/// a no-op and the result steps between a few shapes), so [`gaussian_blur_exact`] is used instead.
const EXACT_SIGMA_BELOW: f64 = 3.0;
/// True when a Gaussian of these per-axis sigmas should use [`gaussian_blur_exact`].
pub fn gaussian_is_exact(sigma_x: f64, sigma_y: f64) -> bool {
sigma_x.max(sigma_y) < EXACT_SIGMA_BELOW
}
/// One separable pass of a sampled Gaussian (radius 3 sigma) along x or y.
fn gauss_pass(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image {
// Below 1e-3 the kernel is the identity in f64; tiny sigmas would also make 2σ² underflow to 0 (NaN taps).
if sigma.is_nan() || sigma <= 1e-3 || img.is_empty() {
return img.clone();
}
let r = (sigma * 3.0).ceil() as i64;
let mut k: Vec<f64> = (-r..=r).map(|i| (-((i * i) as f64) / (2.0 * sigma * sigma)).exp()).collect();
let total: f64 = k.iter().sum();
k.iter_mut().for_each(|v| *v /= total);
let w = img.width as usize;
let mut out = Image::new(img.width, img.height);
out.rows_mut().for_each(|(y, row)| {
for x in 0..w {
let mut acc = [0.0f64; 4];
for (i, wt) in k.iter().enumerate() {
let d = i as i64 - r;
let (sx, sy) = if vertical { (x as i64, y as i64 + d) } else { (x as i64 + d, y as i64) };
let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) };
for c in 0..4 {
acc[c] += p[c] as f64 * wt;
}
}
row[x] = acc.map(|v| v as f32);
}
});
out
}
/// Gaussian blur as a true sampled kernel, continuous in sigma (for small sigmas, see
/// [`gaussian_is_exact`]).
pub fn gaussian_blur_exact(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image {
gauss_pass(&gauss_pass(img, sigma_x, false, repeat), sigma_y, true, repeat)
}
/// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from
/// up) over `length` pixels.
pub fn directional_blur(img: &Image, angle_deg: f64, length: f64) -> Image {
@@ -228,6 +272,17 @@ mod tests {
assert!(b.get(32, 32)[3] < 0.05);
}
#[test]
fn vanishing_or_nan_sigma_leaves_the_image_unchanged() {
let mut img = Image::new(5, 5);
img.set(2, 2, [1.0, 0.5, 0.25, 1.0]);
for s in [1e-200, f64::MIN_POSITIVE, f64::NAN] {
let b = gaussian_blur_exact(&img, s, s, false);
assert!(b.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "sigma {s}");
assert_eq!(b, img, "sigma {s}");
}
}
#[test]
fn flat_image_stays_flat_with_repeat() {
let img = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
+1 -1
View File
@@ -16,7 +16,7 @@ pub mod warp;
use effectcraft_color::{BlendMode, blend_pixel};
use rayon::prelude::*;
pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur};
pub use blur::{box_blur, directional_blur, gaussian_blur, gaussian_blur_exact, gaussian_is_exact, radial_blur};
pub use channels3d::AuxChannels;
pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample};
+5 -2
View File
@@ -1515,8 +1515,11 @@ function AVItem() {}
AVItem.prototype = Object.create(Item.prototype);
(function () {
function def(name, get, set) { Object.defineProperty(AVItem.prototype, name, { get: get, set: set }); }
def("width", function () { return this.__info().width; }, function (v) { this.__settings({ width: Math.round(__num(v, "width")) }); });
def("height", function () { return this.__info().height; }, function (v) { this.__settings({ height: Math.round(__num(v, "height")) }); });
// `anchor: 0` (top left): After Effects' width and height setters resize the frame and leave the
// layers at their coordinates. Re-anchoring the old frame in the new one belongs to the
// Composition Settings dialog, which is what `comp.settings` defaults to (#1942).
def("width", function () { return this.__info().width; }, function (v) { this.__settings({ width: Math.round(__num(v, "width")), anchor: 0 }); });
def("height", function () { return this.__info().height; }, function (v) { this.__settings({ height: Math.round(__num(v, "height")), anchor: 0 }); });
def("pixelAspect", function () { return this.__info().pixelAspect; }, function (v) { this.__settings({ pixelAspect: __num(v) }); });
def("duration", function () { return this.__info().duration; }, function (v) { this.__settings({ duration: __num(v, "duration") }); });
def("frameRate", function () { return this.__info().frameRate; }, function (v) { this.__settings({ frameRate: __num(v, "frameRate") }); });
+27
View File
@@ -1674,3 +1674,30 @@ fn set_value_rejects_an_array_of_the_wrong_dimension() {
let (_, c) = comp_named(&s, "Dims");
assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([120.0, 34.0, 0.0]));
}
/// #1942: the scripting width and height setters resize the frame only. Re-anchoring the old frame
/// in the new one, which moves every unparented layer, belongs to the Composition Settings dialog.
#[test]
fn setting_comp_width_and_height_leaves_the_layers_where_they_are() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("w", 400, 200, 1, 1, 25);
var l = c.layers.addSolid([1, 1, 1], "s", 50, 50, 1, 1);
var pos = l.property("ADBE Transform Group").property("ADBE Position");
pos.setValue([100, 100]);
var k = c.layers.addSolid([1, 1, 1], "k", 50, 50, 1, 1);
var kpos = k.property("ADBE Transform Group").property("ADBE Position");
kpos.setValueAtTime(0, [10, 20]);
kpos.setValueAtTime(1, [30, 40]);
var before = "" + pos.value;
c.width = 800;
c.height = 400;
[before, "" + pos.value, "" + kpos.keyValue(1), "" + kpos.keyValue(2), c.width, c.height]
"#,
);
assert_eq!(o.result, json!(["100,100,0", "100,100,0", "10,20,0", "30,40,0", 800, 400]));
let (_, c) = comp_named(&s, "w");
assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 100.0, 0.0]));
}
-8
View File
@@ -371,7 +371,6 @@ const JAPANESE: &[(&str, &str)] = &[
("Latest auto-save: {}", "最新の自動保存:{}"),
("Layer", "レイヤー"),
("Layer controls", "レイヤーコントロール"),
("Layer {}", "レイヤー{}"),
("Layer:", "レイヤー:"),
("Layer: {}", "レイヤー:{}"),
("Layer Styles", "レイヤースタイル"),
@@ -1107,7 +1106,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Latest auto-save: {}", "最新的自动保存:{}"),
("Layer", "图层"),
("Layer controls", "图层控制"),
("Layer {}", "图层 {}"),
("Layer:", "图层:"),
("Layer: {}", "图层:{}"),
("Layer Styles", "图层样式"),
@@ -1848,7 +1846,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Latest auto-save: {}", "最新的自動儲存:{}"),
("Layer", "圖層"),
("Layer controls", "圖層控制"),
("Layer {}", "圖層 {}"),
("Layer:", "圖層:"),
("Layer: {}", "圖層:{}"),
("Layer Styles", "圖層樣式"),
@@ -2543,7 +2540,6 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Latest auto-save: {}", "Останнє автозбереження: {}"),
("Layer", "Шар"),
("Layer controls", "Керування шарами"),
("Layer {}", "Шар {}"),
("Layer:", "Шар:"),
("Layer: {}", "Шар: {}"),
("Layer Styles", "Стилі шару"),
@@ -3102,7 +3098,6 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Latest auto-save: {}", "Último salvamento automático: {}"),
("Layer", "Camada"),
("Layer controls", "Controles da camada"),
("Layer {}", "Camada {}"),
("Layer:", "Camada:"),
("Layer: {}", "Camada: {}"),
("Layer Styles", "Estilos de camada"),
@@ -3562,7 +3557,6 @@ const CZECH: &[(&str, &str)] = &[
("Latest auto-save: {}", "Nejnovější automatická záloha: {}"),
("Layer", "Vrstva"),
("Layer controls", "Ovládací prvky vrstvy"),
("Layer {}", "Vrstva {}"),
("Layer:", "Vrstva:"),
("Layer: {}", "Vrstva: {}"),
("Layer Styles", "Styly vrstvy"),
@@ -4126,7 +4120,6 @@ const SPANISH: &[(&str, &str)] = &[
("Latest auto-save: {}", "Último guardado automático: {}"),
("Layer", "Capa"),
("Layer controls", "Controles de capa"),
("Layer {}", "Capa {}"),
("Layer:", "Capa:"),
("Layer: {}", "Capa: {}"),
("Layer Styles", "Estilos de capa"),
@@ -4678,7 +4671,6 @@ const GEORGIAN: &[(&str, &str)] = &[
("Latest auto-save: {}", "ბოლო ავტოშენახვა: {}"),
("Layer", "შრე"),
("Layer controls", "შრის მართვის ელემენტები"),
("Layer {}", "შრე {}"),
("Layer:", "შრე:"),
("Layer: {}", "შრე: {}"),
("Layer Styles", "შრის სტილები"),
+22 -14
View File
@@ -29,7 +29,7 @@ pub struct CameraDraft {
pub struct LightDraft {
pub layer: Option<u64>,
pub name: String,
pub kind: usize,
pub kind: LightKind,
pub color: [f32; 3],
pub intensity: f64,
pub cone_angle: f64,
@@ -42,9 +42,14 @@ pub struct LightDraft {
pub shadow_diffusion: f64,
}
const KINDS: [LightKind; 4] = [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient];
const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"];
/// Whether a light type has Falloff and Shadows options. Ambient and Environment lights come
/// from no one place, so they have neither.
fn falls_off(kind: LightKind) -> bool {
!matches!(kind, LightKind::Ambient | LightKind::Environment)
}
fn comp_w(app: &EffectcraftApp) -> f64 {
app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0)
}
@@ -105,7 +110,7 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option<u64>) -> Result<(), St
LightDraft {
layer: Some(l.id.0),
name: l.name.clone(),
kind: KINDS.iter().position(|k| *k == kind).unwrap_or(1),
kind,
color: [c[0], c[1], c[2]],
intensity: f("intensity", 100.0),
cone_angle: f("coneAngle", 90.0),
@@ -123,7 +128,7 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option<u64>) -> Result<(), St
LightDraft {
layer: None,
name: format!("Light {n}"),
kind: 1,
kind: LightKind::Spot,
color: [1.0, 1.0, 1.0],
intensity: 100.0,
cone_angle: 90.0,
@@ -263,11 +268,13 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
crate::widgets::focus_on_open(ui.ctx(), &r, &d.name);
ui.end_row();
ui.label(tr("Light Type"));
egui::ComboBox::from_id_salt("light-type").selected_text(KINDS[d.kind.min(3)].label()).show_ui(ui, |ui| {
for (i, k) in KINDS.iter().enumerate() {
ui.selectable_value(&mut d.kind, i, k.label());
let combo = egui::ComboBox::from_id_salt("light-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| {
for (i, k) in LightKind::ALL.into_iter().enumerate() {
let item = ui.selectable_value(&mut d.kind, k, tr(k.label()));
app.auto.add(&format!("dialog.light.typeItem.{i}"), item.rect, k.label());
}
});
app.auto.add_response("dialog.light.type", &combo.response, "Light Type");
ui.end_row();
ui.label(tr("Color"));
crate::widgets::srgb_color_button(ui, &mut d.color);
@@ -275,8 +282,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.label(tr("Intensity"));
ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")));
ui.end_row();
let kind = KINDS[d.kind.min(3)];
if kind == LightKind::Spot {
if d.kind == LightKind::Spot {
ui.label(tr("Cone Angle"));
ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°")));
ui.end_row();
@@ -284,7 +290,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %")));
ui.end_row();
}
if kind != LightKind::Ambient {
if falls_off(d.kind) {
ui.label(tr("Falloff"));
egui::ComboBox::from_id_salt("light-falloff").selected_text(FALLOFFS[d.falloff.min(2)]).show_ui(ui, |ui| {
for (i, f) in FALLOFFS.iter().enumerate() {
@@ -312,15 +318,17 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
});
ui.add_space(14.0);
ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim));
ui.add_space(8.0);
if falls_off(d.kind) {
ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim));
ui.add_space(8.0);
}
rects = buttons(ui, t, &mut ok, &mut close);
});
app.auto.add("dialog.light.ok", rects.0, "OK");
app.auto.add("dialog.light.cancel", rects.1, "Cancel");
app.dialog_state.light = d.clone();
if ok || super::dialogs::ui_enter(ctx) {
let kind = KINDS[d.kind.min(3)];
let kind = d.kind;
let mut p = json!({
"name": d.name,
"kind": kind.label(),
@@ -331,7 +339,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
p["coneAngle"] = json!(d.cone_angle);
p["coneFeather"] = json!(d.cone_feather);
}
if kind != LightKind::Ambient {
if falls_off(kind) {
p["falloff"] = json!(d.falloff);
p["radius"] = json!(d.radius);
p["falloffDistance"] = json!(d.falloff_distance);
+2 -11
View File
@@ -441,19 +441,10 @@ fn prop_row(
let comp = app.session.active_comp_arc();
if let Some(comp) = comp {
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0));
let idx_of = |id: u64| comp.layers.iter().position(|x| x.id.0 == id);
let name = l.and_then(|id| idx_of(id).map(|i| format!("{}. {}", i + 1, comp.layers[i].name))).unwrap_or_else(|| "None".into());
let pop = egui::Id::new(("ec-lpop", uid));
if widgets::dropdown(ui, dr, &name, &t, egui::Id::new(("ec-l", uid))).clicked() {
widgets::open_popup(ui, pop);
if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::new(("ec-l", uid))) {
set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
let mut opts = vec!["None".to_string()];
opts.extend(comp.layers.iter().enumerate().map(|(i, l)| format!("{}. {}", i + 1, l.name)));
let cur = l.and_then(idx_of).map(|i| i + 1).unwrap_or(0);
if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(cur)) {
set(actions, if i == 0 { serde_json::Value::Null } else { json!(comp.layers[i - 1].id.0) });
}
// Source / Masks / Effects & Masks (the hidden companion parameter).
if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) {
let si = src.value.as_enum() as usize;
+9 -2
View File
@@ -166,12 +166,13 @@ pub fn encode_thumb(img: &egui::ColorImage) -> String {
out
}
/// Decode a stored hex thumbnail, rejecting malformed or non-ASCII text.
pub fn decode_thumb(text: &str) -> Option<egui::ColorImage> {
let t = text.trim();
if t.len() != THUMB_W * THUMB_H * 6 {
if t.len() != THUMB_W * THUMB_H * 6 || !t.is_ascii() {
return None;
}
let b: Vec<u8> = (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16)).collect::<Result<_, _>>().ok()?;
let b: Vec<u8> = (0..t.len()).step_by(2).map(|i| u8::from_str_radix(t.get(i..i + 2)?, 16).ok()).collect::<Option<_>>()?;
let px = b.as_chunks::<3>().0.iter().map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect();
Some(egui::ColorImage::new([THUMB_W, THUMB_H], px))
}
@@ -496,6 +497,12 @@ fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str
mod tests {
use super::*;
#[test]
fn non_ascii_thumbnail_is_rejected_without_panicking() {
let malformed = format!("aé{}", "0".repeat(THUMB_W * THUMB_H * 6 - 3));
assert!(decode_thumb(&malformed).is_none());
}
#[test]
fn recent_list_comes_from_settings() {
let mut prefs = effectcraft_engine::prefs::Prefs::default();
+14
View File
@@ -908,6 +908,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "name".into(), it.name.clone())));
ui.close();
}
let duplicate_items = targets(app, *id, selected);
// Like Edit > Duplicate: folders disable the whole operation, including mixed selections.
let can_duplicate =
!duplicate_items.is_empty() && duplicate_items.iter().all(|id| app.session.project.item(ItemId(*id)).is_some_and(|i| !i.is_folder()));
let mut duplicate = egui::Button::new(crate::i18n::tr("Duplicate"));
if let Some(shortcut) = app.session.shortcuts().shortcut_of("edit.duplicate", &serde_json::Value::Null) {
duplicate = duplicate.shortcut_text(crate::menus::shortcut_text(shortcut));
}
let response = ui.add_enabled(can_duplicate, duplicate);
app.auto.add("project.context.duplicate", response.rect, "Duplicate");
if response.clicked() {
actions.push(("project.duplicate".into(), json!({"items": duplicate_items})));
ui.close();
}
if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() {
actions.push(("project.move".into(), json!({"items": targets(app, *id, selected), "folder": null})));
ui.close();
+10 -7
View File
@@ -4403,13 +4403,16 @@ fn value_editor(
}
Value::Text(_) => {}
Value::Layer(l) => {
p.text(
pos2(x, at.y),
Align2::LEFT_CENTER,
l.map(|l| crate::i18n::tr_args("Layer {}", &[&l])).unwrap_or(crate::i18n::tr("None").into()),
Tokens::ui(12.0),
t.hot_text,
);
// Layer references (a light's Source, a Displacement Map's layer…) pick from the
// comp's layers, as in Effect Controls; the choice is written through the same
// undoable prop.set as the other value arms.
let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
if let Some(comp) = app.session.active_comp_arc()
&& let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::new(("tl-lay", uid)))
{
set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
}
Value::Gradient(_) => {
// Opens the Gradient Editor at the current time (#612).
+38
View File
@@ -557,6 +557,44 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String]
})
}
/// The drop-down for a layer-valued property (`ParamUi::Layer`): the current layer shown as
/// "N. name" (or "None"), and a popup of "None" plus every layer of the comp. Effect Controls
/// and the Timeline share it so a layer reference (an Environment light's Source, a Displacement
/// Map's layer) is picked the same way in every panel, and the reference is shown as its layer's
/// index and name rather than its internal id. Returns `Some(chosen)` when the user picked —
/// the layer's id, or `None` for "None" — and `None` while nothing is chosen; the caller writes
/// it as an undoable `prop.set` (id, or null for "None").
pub fn layer_picker(
ui: &mut Ui,
rect: Rect,
layers: &[effectcraft_engine::project::Layer],
current: Option<u64>,
t: &Tokens,
id: egui::Id,
) -> Option<Option<u64>> {
let index_of = |wanted: u64| layers.iter().position(|l| l.id.0 == wanted);
let entry = |i: usize| layers.get(i).map(|l| format!("{}. {}", i + 1, l.name));
let current_index = current.and_then(index_of);
let label = current_index.and_then(entry).unwrap_or_else(|| crate::i18n::tr("None").to_string());
let pop = id.with("pop");
if dropdown(ui, rect, &label, t, id).clicked() {
open_popup(ui, pop);
}
if !popup_is_open(ui, pop) {
return None;
}
// Built only while open: a comp can hold hundreds of layers.
let mut options: Vec<String> = Vec::with_capacity(layers.len() + 1);
options.push(crate::i18n::tr("None").to_string());
options.extend((0..layers.len()).filter_map(entry));
// The current entry is highlighted (the first option is "None").
match popup_menu(ui, pop, rect.left_bottom(), &options, Some(current_index.map_or(0, |i| i + 1))) {
Some(0) => Some(None),
Some(picked) => layers.get(picked - 1).map(|l| Some(l.id.0)),
None => None,
}
}
/// The open popup list `id` at `pos`, drawn by `body` (`rows` entries and `seps` separators,
/// for its height): as tall as the window allows, scrolling beyond with a scroll bar (a shape
/// layer's Add menu has 20 entries, the blend modes 40, the fonts hundreds), and moved up when
+3
View File
@@ -34,6 +34,8 @@ mod ui_inline_edit;
mod ui_keyframes;
mod ui_large_project;
mod ui_layer_panel_zoom;
mod ui_layer_source_picker;
mod ui_light_settings;
mod ui_live_preview;
mod ui_locked_color;
mod ui_media_browser_cache;
@@ -48,6 +50,7 @@ mod ui_parameter_spacing;
mod ui_presets_folder;
mod ui_preview_align;
mod ui_project_delete;
mod ui_project_duplicate;
mod ui_project_keyboard;
mod ui_project_new_folder;
mod ui_project_rows;
@@ -0,0 +1,207 @@
//! Picking a layer-valued property with the shared drop-down: the Timeline draws a layer-valued
//! Light Options property (an Environment light's Source) as a drop-down of the comp's layers,
//! Effect Controls' layer parameters (a Displacement Map's layer) pick through the same widget,
//! and both write an undoable `prop.set`.
use effectcraft_engine::Session;
use effectcraft_engine::keyframe::Value;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::NodeT;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) {
h.event(egui::Event::PointerMoved(at));
h.step();
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
}
fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation element {id}")).clone();
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
/// Whether `want` is painted inside `row` (the drop-down's current-value text).
fn painted_in(h: &Harness<'_, EffectcraftApp>, row: Rect, want: &str) -> bool {
h.output().shapes.iter().any(|c| match &c.shape {
egui::Shape::Text(t) => t.galley.text() == want && row.expand(3.0).contains(t.pos),
_ => false,
})
}
/// The current value of a layer-valued property, by uid.
fn layer_value(h: &Harness<'_, EffectcraftApp>, layer: u64, prop: u64) -> Option<u64> {
let comp = h.state().session.active_comp().unwrap();
match &comp.layer(LayerId(layer)).unwrap().props.find(prop).unwrap().value {
Value::Layer(v) => *v,
other => panic!("property {prop} is not layer-valued: {other:?}"),
}
}
/// The "N. name" a drop-down shows for a layer.
fn entry_for(h: &Harness<'_, EffectcraftApp>, layer: u64) -> String {
let comp = h.state().session.active_comp().unwrap();
let i = comp.layers.iter().position(|l| l.id.0 == layer).unwrap();
format!("{}. {}", i + 1, comp.layers[i].name)
}
/// Puts the layer-valued `prop` of `layer` back to `value` with a direct `prop.set` (no merge key,
/// so the pick below records its own step instead of folding into a picker write), picks `none`
/// from the drop-down at `value_id`, and checks the clear takes one undo step that restores `value`.
#[track_caller]
fn none_pick_clears(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, none: &str, layer: u64, prop: u64, value: u64) {
h.state_mut().session.execute("prop.set", json!({"layer": layer, "prop": prop, "value": value})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(h, layer, prop), Some(value));
let steps = h.state().session.history.undo.len();
let at = rect_of(h, value_id).center();
click(h, at);
h.get_by_label(none).click();
h.run_steps(3);
assert_eq!(layer_value(h, layer, prop), None, "selecting None clears the value");
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one clear undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(h, layer, prop), Some(value), "undo restores the selected layer");
}
/// A light's Source (a layer-valued Light Options property) picks a comp layer from the
/// Timeline and undo restores None.
#[test]
fn timeline_source_drop_down_picks_the_layer_and_undo_restores_none() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap();
s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap();
let sky = s.execute("layer.newSolid", json!({"name": "Sky HDRI", "color": "#66aaff"})).unwrap()["layer"].as_u64().unwrap();
let light = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env Light"})).unwrap()["layer"].as_u64().unwrap();
let (group, source) = {
let comp = s.active_comp().unwrap();
let opts = comp.layer(LayerId(light)).unwrap().props.sub("lightOptions").unwrap();
(opts.uid, opts.get("source").unwrap().uid)
};
let mut app = EffectcraftApp::new(s);
app.ui.timeline.open_layers.insert(light);
app.ui.timeline.open_groups.insert(group);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(4);
let value_id = format!("timeline.prop.{source}.value");
// Unset: the row is a drop-down reading "None" — an element to click, not bare text.
assert_eq!(layer_value(&h, light, source), None);
assert!(painted_in(&h, rect_of(&h, &value_id), "None"), "the Source row shows a None drop-down");
// Opening it lists "None" and every layer of the comp, by index and name.
let at = rect_of(&h, &value_id).center();
click(&mut h, at);
let entry = entry_for(&h, sky);
assert!(h.query_by_label("None").is_some(), "the list starts with None");
assert!(h.query_by_label(&entry).is_some(), "the comp's layers are listed ({entry})");
let steps = h.state().session.history.undo.len();
h.get_by_label(&entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, light, source), Some(sky), "the Source holds the layer id");
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
// The row names the layer (index and name), not its internal id.
assert!(painted_in(&h, rect_of(&h, &value_id), &entry), "the Source row shows the layer's name");
// Dismissing the list without choosing writes nothing; reopening marks the chosen layer as
// the current entry (and "None" as not current).
click(&mut h, pos2(1500.0, 300.0));
h.run_steps(2);
assert_eq!(layer_value(&h, light, source), Some(sky), "dismissing the list writes nothing");
let reopen = rect_of(&h, &value_id).center();
click(&mut h, reopen);
assert_eq!(h.get_by_label(&entry).accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current entry is highlighted");
assert_eq!(h.get_by_label("None").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False), "None is not the current entry");
click(&mut h, pos2(1500.0, 300.0));
h.run_steps(2);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(&h, light, source), None, "undo restores None");
none_pick_clears(&mut h, &value_id, "None", light, source, sky);
}
/// The shared widget is the one Effect Controls already used: a Displacement Map's layer
/// parameter still picks from the drop-down and still undoes.
#[test]
fn effect_controls_layer_param_picks_through_the_shared_widget() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap();
let map = s.execute("layer.newSolid", json!({"name": "Disp", "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let base = s.execute("layer.newSolid", json!({"name": "Base", "color": "#20c040"})).unwrap()["layer"].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layers": [base], "effect": "Displacement Map"})).unwrap()["effects"][0].as_u64().unwrap();
let prop = {
let comp = s.active_comp().unwrap();
comp.layer(LayerId(base)).unwrap().effects().unwrap().find_group(fx).unwrap().get("displacementMapLayer").unwrap().uid
};
s.state.selected_layers = vec![LayerId(base)];
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
let mut h = Harness::builder().with_size(vec2(1600.0, 1100.0)).build_eframe(|_| app);
h.run_steps(4);
let value_id = format!("effectControls.prop.{prop}.value");
let row = rect_of(&h, &value_id);
click(&mut h, row.center());
let entry = entry_for(&h, map);
assert!(h.query_by_label(&entry).is_some(), "the comp's layers are listed ({entry})");
let steps = h.state().session.history.undo.len();
h.get_by_label(&entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, base, prop), Some(map), "the parameter holds the layer id");
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
// The row names the picked layer, as the Timeline's Source row does.
assert!(painted_in(&h, rect_of(&h, &value_id), &entry), "the parameter row shows the layer's name");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(&h, base, prop), None, "undo restores None");
none_pick_clears(&mut h, &value_id, "None", base, prop, map);
}
/// The visual record (the `ui_3d` ignored-snapshot convention): the Source drop-down open over
/// the layer list, and the row after a layer is chosen. Renders with wgpu when `EC_SNAPSHOT_DIR`
/// is set: `EC_SNAPSHOT_DIR=shots cargo test -p effectcraft-ui-egui --test it ui_layer_source_picker:: -- --ignored`.
#[test]
#[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"]
fn snapshot_source_dropdown_open_and_chosen() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 1280, "height": 720, "frameRate": 30, "duration": 5})).unwrap();
s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap();
let sky = s.execute("layer.newSolid", json!({"name": "Sky HDRI", "color": "#66aaff"})).unwrap()["layer"].as_u64().unwrap();
let light = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env Light"})).unwrap()["layer"].as_u64().unwrap();
let (group, source) = {
let comp = s.active_comp().unwrap();
let opts = comp.layer(LayerId(light)).unwrap().props.sub("lightOptions").unwrap();
(opts.uid, opts.get("source").unwrap().uid)
};
let mut app = EffectcraftApp::new(s);
app.ui.timeline.open_layers.insert(light);
app.ui.timeline.open_groups.insert(group);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).wgpu().build_eframe(|_| app);
h.run_steps(4);
let save = |h: &mut Harness<'_, EffectcraftApp>, name: &str| {
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
h.render().expect("render").save(format!("{dir}/{name}")).expect("save");
}
};
save(&mut h, "source_unset.png");
let at = rect_of(&h, &format!("timeline.prop.{source}.value")).center();
click(&mut h, at);
save(&mut h, "source_dropdown_open.png");
let entry = entry_for(&h, sky);
h.get_by_label(&entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, light, source), Some(sky));
save(&mut h, "source_chosen.png");
}
@@ -0,0 +1,201 @@
//! Light Settings dialog (Layer ▸ New ▸ Light, Layer Settings on a light): every light type the
//! engine has, Environment included, and an existing light's type kept when it's reopened (#1588).
use effectcraft_engine::Session;
use effectcraft_engine::project::{Layer, LayerId, LayerSource, LightKind};
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui_kittest::{Harness, kittest::Queryable as _};
use serde_json::{Value, json};
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Lights", "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, so the frame before can be stale).
fn element(h: &Harness<'_, EffectcraftApp>, id: &str) -> Option<effectcraft_ui_egui::automation::Element> {
let auto = &h.state().auto;
auto.elements.iter().chain(auto.previous.iter()).find(|e| e.id == id).cloned()
}
fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let [x, y, w, hh] = element(h, id).unwrap_or_else(|| panic!("{id} is registered")).rect;
let p = egui::pos2(x + w / 2.0, y + hh / 2.0);
h.input_mut().events.push(egui::Event::PointerMoved(p));
h.step();
h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
}
fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) {
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap();
h.run_steps(2);
}
/// Picks the dialog's Light Type `kind` from its menu.
fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: LightKind) {
click_id(h, "dialog.light.type");
let i = LightKind::ALL.iter().position(|k| *k == kind).unwrap();
click_id(h, &format!("dialog.light.typeItem.{i}"));
}
fn ok(h: &mut Harness<'_, EffectcraftApp>) {
click_id(h, "dialog.light.ok");
assert_eq!(h.state().dialog, None);
}
fn layer(h: &Harness<'_, EffectcraftApp>, id: u64) -> Layer {
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
fn kind(h: &Harness<'_, EffectcraftApp>, id: u64) -> LightKind {
match layer(h, id).source {
LayerSource::Light { kind } => kind,
other => panic!("not a light: {other:?}"),
}
}
fn last_layer(h: &Harness<'_, EffectcraftApp>) -> u64 {
h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).max().unwrap()
}
/// Opens Light Settings for an existing light, as Layer Settings does.
fn reopen(h: &mut Harness<'_, EffectcraftApp>, id: u64) {
invoke(h, "layer.settings", json!({"layer": id}));
assert_eq!(h.state().dialog, Some(Dialog::LightSettings));
}
/// An Environment light lit from a solid, its image turned 30°.
fn environment_light(h: &mut Harness<'_, EffectcraftApp>) -> (u64, u64) {
let s = &mut h.state_mut().session;
let sky = s.execute("layer.newSolid", json!({"width": 256, "height": 128, "name": "Sky"})).unwrap()["layer"].as_u64().unwrap();
let env = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env", "intensity": 80})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/source", "value": sky})).unwrap();
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/rotation", "value": 30})).unwrap();
h.run_steps(2);
(env, sky)
}
#[test]
fn the_light_type_menu_lists_every_light_type() {
let mut h = harness();
invoke(&mut h, "layer.newLight", json!({}));
click_id(&mut h, "dialog.light.type");
for (i, k) in LightKind::ALL.iter().enumerate() {
let item = element(&h, &format!("dialog.light.typeItem.{i}")).map(|e| e.label);
assert_eq!(item.as_deref(), Some(k.label()));
}
}
#[test]
fn new_light_creates_an_environment_light() {
let mut h = harness();
invoke(&mut h, "layer.newLight", json!({}));
choose(&mut h, LightKind::Environment);
ok(&mut h);
let id = last_layer(&h);
assert_eq!(kind(&h, id), LightKind::Environment);
let l = layer(&h, id);
assert!(l.props.prop("lightOptions/source").is_some() && l.props.prop("lightOptions/falloff").is_none());
// One undoable step, redone as Environment.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(h.state().session.active_comp().unwrap().layer(LayerId(id)).is_none());
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
assert_eq!(kind(&h, id), LightKind::Environment);
}
#[test]
fn reopening_an_environment_light_and_pressing_ok_keeps_it() {
let mut h = harness();
let (env, sky) = environment_light(&mut h);
let before = layer(&h, env);
reopen(&mut h, env);
ok(&mut h);
let after = layer(&h, env);
assert_eq!(kind(&h, env), LightKind::Environment, "not turned into a Spot light");
assert_eq!(after.props.prop("lightOptions/source").unwrap().value.as_layer(), Some(sky));
for path in ["lightOptions/rotation", "lightOptions/intensity"] {
assert_eq!(after.props.prop(path).unwrap().value, before.props.prop(path).unwrap().value, "{path}");
}
}
#[test]
fn cancelling_light_settings_changes_nothing() {
let mut h = harness();
let (env, _) = environment_light(&mut h);
let before = serde_json::to_value(layer(&h, env)).unwrap();
let steps = h.state().session.history.undo.len();
reopen(&mut h, env);
choose(&mut h, LightKind::Spot);
click_id(&mut h, "dialog.light.cancel");
assert_eq!(h.state().dialog, None);
assert_eq!(serde_json::to_value(layer(&h, env)).unwrap(), before);
assert_eq!(h.state().session.history.undo.len(), steps);
}
#[test]
fn changing_a_light_to_environment_undoes_and_redoes() {
let mut h = harness();
let id = h.state_mut().session.execute("layer.newLight", json!({"kind": "Spot", "coneAngle": 45})).unwrap()["layer"].as_u64().unwrap();
h.run_steps(2);
reopen(&mut h, id);
choose(&mut h, LightKind::Environment);
ok(&mut h);
assert_eq!(kind(&h, id), LightKind::Environment);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(kind(&h, id), LightKind::Spot);
assert_eq!(layer(&h, id).props.prop("lightOptions/coneAngle").unwrap().value.as_f64(), 45.0);
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
assert_eq!(kind(&h, id), LightKind::Environment);
}
#[test]
fn the_other_light_types_are_created_and_kept() {
let mut h = harness();
for k in [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient] {
invoke(&mut h, "layer.newLight", json!({}));
choose(&mut h, k);
ok(&mut h);
let id = last_layer(&h);
assert_eq!(kind(&h, id), k);
reopen(&mut h, id);
ok(&mut h);
assert_eq!(kind(&h, id), k, "{} kept on reopening", k.label());
}
}
#[test]
fn falloff_and_shadow_options_show_only_for_lights_that_have_them() {
let mut h = harness();
invoke(&mut h, "layer.newLight", json!({}));
for k in LightKind::ALL {
choose(&mut h, k);
let shown = !matches!(k, LightKind::Ambient | LightKind::Environment);
for label in ["Falloff", "Casts Shadows", "Shadow Darkness"] {
assert_eq!(h.query_by_label(label).is_some(), shown, "{label} for {}", k.label());
}
}
}
#[test]
fn changing_an_environment_light_to_spot_undoes_to_its_source_and_rotation() {
let mut h = harness();
let (env, sky) = environment_light(&mut h);
reopen(&mut h, env);
choose(&mut h, LightKind::Spot);
ok(&mut h);
assert_eq!(kind(&h, env), LightKind::Spot);
assert!(layer(&h, env).props.prop("lightOptions/source").is_none());
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
let l = layer(&h, env);
assert_eq!(kind(&h, env), LightKind::Environment);
assert_eq!(l.props.prop("lightOptions/source").unwrap().value.as_layer(), Some(sky));
assert_eq!(l.props.prop("lightOptions/rotation").unwrap().value.as_f64(), 30.0);
}
@@ -0,0 +1,172 @@
//! #2207: Duplicate is reachable from a Project row's context menu, with the same targets,
//! naming, undo and shortcut preset as Edit > Duplicate.
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use effectcraft_ui_egui::menus::{parse_shortcut, shortcut_text};
use egui::{Event, Key, Modifiers, PointerButton, Rect, Shape, pos2};
use egui_kittest::Harness;
use serde_json::json;
const DUPLICATE: &str = "project.context.duplicate";
fn harness() -> (Harness<'static, EffectcraftApp>, ItemId, ItemId, ItemId) {
let mut s = Session::default();
let mut comp =
|name: &str| ItemId(s.execute("comp.new", json!({"name": name, "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
let main = comp("Main");
let other = comp("Other");
let folder = ItemId(s.execute("project.newFolder", json!({"name": "Bin"})).unwrap()["item"].as_u64().unwrap());
s.execute("project.select", json!({"items": []})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
(h, main, other, folder)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, button: PointerButton, modifiers: Modifiers) {
let p = rect(h, id).center();
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: true, modifiers });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: false, modifiers });
h.run_steps(3);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.step();
}
fn row(h: &mut Harness<'_, EffectcraftApp>, item: ItemId, button: PointerButton, modifiers: Modifiers) {
click(h, &format!("project.item.{}.name", item.0), button, modifiers);
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
h.step();
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
h.run_steps(2);
}
fn copy_name(h: &Harness<'_, EffectcraftApp>, id: ItemId) -> String {
h.state().session.project.item(id).unwrap().name.clone()
}
fn menu_text(h: &Harness<'_, EffectcraftApp>, text: &str) -> bool {
fn contains(shape: &Shape, bounds: Rect, text: &str) -> bool {
match shape {
Shape::Text(t) => bounds.contains(t.pos) && t.galley.text() == text,
Shape::Vec(shapes) => shapes.iter().any(|s| contains(s, bounds, text)),
_ => false,
}
}
let bounds = rect(h, DUPLICATE);
h.output().shapes.iter().any(|s| contains(&s.shape, bounds, text))
}
#[test]
fn unselected_row_duplicates_only_itself_and_undo_restores_the_project() {
let (mut h, main, other, _) = harness();
row(&mut h, other, PointerButton::Primary, Modifiers::NONE);
let before = h.state().session.project.clone();
let undo = h.state().session.history.undo.len();
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
assert_eq!(h.state().session.state.project_selection, [main]);
assert!(menu_text(&h, &shortcut_text("Cmd+D")), "the default platform shortcut is shown");
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
let selected = h.state().session.state.project_selection.clone();
let [copy] = selected.as_slice() else { panic!("one selected copy") };
assert!(!before.items.contains_key(copy), "the copy has a fresh ID");
assert_eq!(copy_name(&h, *copy), "Main 2");
assert_eq!(copy_name(&h, other), "Other");
assert_eq!(h.state().session.project.items.len(), before.items.len() + 1);
assert_eq!(h.state().session.history.undo.len(), undo + 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.project, before);
}
#[test]
fn selected_rows_duplicate_together_in_selection_order_with_numbered_names() {
let (mut h, main, other, _) = harness();
// Deliberately reverse alphabetical row order: command targets retain selection order.
row(&mut h, other, PointerButton::Primary, Modifiers::NONE);
row(&mut h, main, PointerButton::Primary, Modifiers::COMMAND);
assert_eq!(h.state().session.state.project_selection, [other, main]);
let before = h.state().session.project.clone();
let undo = h.state().session.history.undo.len();
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
let selected = h.state().session.state.project_selection.clone();
let [a, b] = selected.as_slice() else { panic!("two selected copies") };
assert_ne!(a, b);
assert!(selected.iter().all(|id| !before.items.contains_key(id)));
assert_eq!((copy_name(&h, *a), copy_name(&h, *b)), ("Other 2".to_string(), "Main 2".to_string()));
assert_eq!(h.state().session.project.items.len(), before.items.len() + 2);
assert_eq!(h.state().session.history.undo.len(), undo + 1);
// Duplicate the selected copies again: the existing engine numbering continues.
row(&mut h, *b, PointerButton::Secondary, Modifiers::NONE);
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
let names: Vec<_> = h.state().session.state.project_selection.iter().map(|id| copy_name(&h, *id)).collect();
assert_eq!(names, ["Other 3", "Main 3"]);
}
#[test]
fn folders_and_mixed_selections_disable_duplicate_without_partial_changes() {
for mixed in [false, true] {
let (mut h, main, _, folder) = harness();
row(&mut h, main, PointerButton::Primary, Modifiers::NONE);
if mixed {
row(&mut h, folder, PointerButton::Primary, Modifiers::COMMAND);
}
// An unselected folder replaces the selection; a selected non-folder keeps the mixed set.
row(&mut h, if mixed { main } else { folder }, PointerButton::Secondary, Modifiers::NONE);
let before = h.state().session.project.clone();
let selected = h.state().session.state.project_selection.clone();
assert!(selected.contains(&folder));
let undo = h.state().session.history.undo.len();
let status = h.state().ui.status.clone();
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
assert_eq!(h.state().session.project, before, "mixed={mixed}: no partial copy");
assert_eq!(h.state().session.state.project_selection, selected);
assert_eq!(h.state().session.history.undo.len(), undo);
assert_eq!(h.state().ui.status, status, "no failing engine command was dispatched");
}
}
#[test]
fn duplicate_shortcut_still_dispatches_and_the_menu_tracks_rebinding_and_unbinding() {
let (mut h, main, _, _) = harness();
row(&mut h, main, PointerButton::Primary, Modifiers::NONE);
assert_eq!(h.state().ui.focused, PanelKind::Project);
let (d, command) = parse_shortcut("Cmd+D").unwrap();
key(&mut h, command, d);
let selected = h.state().session.state.project_selection.clone();
let [copy] = selected.as_slice() else { panic!("one shortcut copy") };
assert_ne!(*copy, main);
assert_eq!(copy_name(&h, *copy), "Main 2");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": "F6"})).unwrap();
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
assert!(menu_text(&h, "F6"));
assert!(!menu_text(&h, &shortcut_text("Cmd+D")));
key(&mut h, Key::Escape, Modifiers::NONE);
row(&mut h, main, PointerButton::Primary, Modifiers::NONE);
let before = h.state().session.project.clone();
key(&mut h, command, d);
assert_eq!(h.state().session.project, before, "the former shortcut stays unbound");
key(&mut h, Key::F6, Modifiers::NONE);
assert_eq!(h.state().session.project.items.len(), before.items.len() + 1);
h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": null})).unwrap();
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
assert!(!menu_text(&h, "F6"));
assert!(!menu_text(&h, &shortcut_text("Cmd+D")));
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
assert_eq!(h.state().session.project.items.len(), before.items.len() + 2, "unbinding keeps the menu action usable");
}
+1 -1
View File
@@ -57,7 +57,7 @@ fn page() -> (Session, Arc<Manual>) {
s.execute("prop.addKey", json!({"layer": l, "path": "effects/#1/transform/offset", "time": 0.0, "value": [0.5, 0.5]})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "effects/#1/transform/offset", "time": 0.5, "value": [0.6, 0.56]})).unwrap();
s.execute("effect.apply", json!({"layers": [l], "effect": "ec.blur.gaussian"})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#2/blurriness", "value": 3.0})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "effects/#2/blurriness", "value": 12.0})).unwrap();
(s, off)
}
+2 -1
View File
@@ -148,7 +148,8 @@ including files overwritten at the same path and byte size.
File ▸ Open tries each missing file at the same place under the project's new folder, without
marking the project modified.
- **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames
added to its folder (a render still in progress) and keeps its interpretation.
added to its folder (a render still in progress) and keeps its interpretation; a still is
read as its one file, even when numbered files sit next to it.
**File ▸ Dependencies ▸ Relink Missing Footage…** searches a chosen folder and its subfolders
for missing source files on the desktop. It relinks unique exact filenames whose decoded type,