mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 15:48:33 +00:00
Merge main into enhance/avi-import
This commit is contained in:
commit
a2f3ac41b1
427 files changed
+24699
-4513
No files matched your search
@@ -4,8 +4,8 @@ Clean-room, pure-Rust AV1 encoder for EffectCraft's MP4 / WebM export (layer L0,
|
||||
no `unsafe`, builds for `wasm32-unknown-unknown`).
|
||||
|
||||
Output is decoded **bit-exactly** by libdav1d (`ffmpeg -c:v libdav1d`, checked in
|
||||
`tests/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived
|
||||
`filmcraft-av1` decoder (`tests/roundtrip.rs`): every decoded frame equals the encoder's own
|
||||
`tests/it/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived
|
||||
`filmcraft-av1` decoder (`tests/it/roundtrip.rs`): every decoded frame equals the encoder's own
|
||||
reconstruction, so prediction never drifts.
|
||||
|
||||
## What is implemented
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Speed / efficiency measurements on ffmpeg-generated test sources (ignored by default):
|
||||
//! `cargo test -p effectcraft-av1enc --test bench -- --ignored --nocapture`.
|
||||
//! `cargo test -p effectcraft-av1enc --test it bench:: -- --ignored --nocapture`.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
File renamed without changes.
@@ -2,7 +2,7 @@
|
||||
//! bit for bit. Skipped when `ffmpeg` (with libdav1d) is not installed. ffmpeg's native `av1`
|
||||
//! decoder only drives hardware decoders, so it is not used.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod bench;
|
||||
mod common;
|
||||
mod ffmpeg_oracle;
|
||||
mod roundtrip;
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Round trips through FilmCraft's spec-derived AV1 decoder: decoded frames must equal the
|
||||
//! encoder's own reconstruction bit for bit.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use common::*;
|
||||
use effectcraft_av1enc::{EncoderConfig, RateControl};
|
||||
+36
-4
@@ -39,7 +39,13 @@ impl Rgba {
|
||||
/// `#rrggbb` or `#rrggbbaa`.
|
||||
pub fn from_hex(s: &str) -> Option<Rgba> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
let p = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok();
|
||||
let p = |i: usize| {
|
||||
let pair = s.get(i..i + 2)?;
|
||||
if !pair.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(pair, 16).ok()
|
||||
};
|
||||
match s.len() {
|
||||
6 => Some(Rgba::from_u8(p(0)?, p(2)?, p(4)?)),
|
||||
8 => Some(Rgba { a: p(6)? as f32 / 255.0, ..Rgba::from_u8(p(0)?, p(2)?, p(4)?) }),
|
||||
@@ -93,7 +99,7 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
|
||||
(((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0)
|
||||
} else if max == g {
|
||||
((b - r) / d + 2.0) / 6.0
|
||||
} else {
|
||||
@@ -130,7 +136,7 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
let h = if d < 1e-7 {
|
||||
0.0
|
||||
} else if max == r {
|
||||
((g - b) / d).rem_euclid(6.0) / 6.0
|
||||
(((g - b) / d).rem_euclid(6.0) / 6.0).rem_euclid(1.0)
|
||||
} else if max == g {
|
||||
((b - r) / d + 2.0) / 6.0
|
||||
} else {
|
||||
@@ -141,7 +147,11 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
}
|
||||
|
||||
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
|
||||
let h6 = h.rem_euclid(1.0) * 6.0;
|
||||
let mut h6 = h.rem_euclid(1.0) * 6.0;
|
||||
// In f32, a tiny negative hue wraps to exactly 1.0; treat it as 0.
|
||||
if h6 >= 6.0 {
|
||||
h6 = 0.0;
|
||||
}
|
||||
let i = h6.floor();
|
||||
let f = h6 - i;
|
||||
let p = v * (1.0 - s);
|
||||
@@ -252,6 +262,28 @@ impl Label {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hsv_to_rgb_tiny_negative_hue_is_red() {
|
||||
let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
|
||||
assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_stays_below_one_for_tiny_blue() {
|
||||
let (h, _, _) = rgb_to_hsv(1.0, 0.0, 1e-9);
|
||||
assert!((0.0..1.0).contains(&h), "rgb_to_hsv hue {h} not in 0..1");
|
||||
let (h, _, _) = rgb_to_hsl(1.0, 0.0, 1e-9);
|
||||
assert!((0.0..1.0).contains(&h), "rgb_to_hsl hue {h} not in 0..1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_signed_hex_pairs() {
|
||||
assert!(Rgba::from_hex("#+1+2+3").is_none());
|
||||
assert!(Rgba::from_hex("#1+2+3+").is_none());
|
||||
assert!(Rgba::from_hex("#ff00+0ff").is_none());
|
||||
assert!(Rgba::from_hex("#a1B2c3").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_roundtrip() {
|
||||
for i in 0..=100 {
|
||||
|
||||
@@ -21,4 +21,5 @@ rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -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: ¶ms, 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]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
+118
-16
@@ -37,16 +37,24 @@ fn index_map(b: &Buf, a: &AuxChannels) -> Vec<Option<usize>> {
|
||||
/// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1).
|
||||
fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec<Option<usize>> {
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let inv = 1.0 / b.scale.max(1e-9);
|
||||
(0..w * h)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]);
|
||||
a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv)
|
||||
let [x, y] = aux_point(b.scale, b.offset, [(i % w) as f64 + at[0], (i / w) as f64 + at[1]]);
|
||||
a.index_at(x, y)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The layer point where 3D Channel effects look up the aux for buffer position `p` (pixel
|
||||
/// `i` + sub-pixel offset) of a buffer with `scale` and `offset`. The Cryptomatte pick calls
|
||||
/// it too, so the two can't pick different aux pixels.
|
||||
#[inline]
|
||||
pub fn aux_point(scale: f64, offset: [f64; 2], p: [f64; 2]) -> [f64; 2] {
|
||||
let inv = 1.0 / scale.max(1e-9);
|
||||
[(p[0] - offset[0]) * inv, (p[1] - offset[1]) * inv]
|
||||
}
|
||||
|
||||
/// A channel sampled into the buffer grid (`fill` outside).
|
||||
fn plane_of(b: &Buf, idx: &[Option<usize>], ch: &[f32], fill: f32) -> Plane {
|
||||
Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() }
|
||||
@@ -326,7 +334,7 @@ fn wild(pat: &str, name: &str) -> bool {
|
||||
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
|
||||
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
|
||||
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
|
||||
fn selection_names(sel: &str) -> Vec<String> {
|
||||
pub(crate) fn selection_names(sel: &str) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut cur = String::new();
|
||||
let mut quote: Option<char> = None;
|
||||
@@ -395,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;
|
||||
}
|
||||
@@ -413,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
|
||||
@@ -436,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;
|
||||
@@ -446,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]]
|
||||
}
|
||||
};
|
||||
@@ -814,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()]
|
||||
}
|
||||
@@ -835,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.
|
||||
|
||||
@@ -55,6 +55,17 @@ impl Curve {
|
||||
/// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or
|
||||
/// fewer than two valid points.
|
||||
pub fn parse(s: &str) -> Option<Curve> {
|
||||
Self::parse_with(s, false)
|
||||
}
|
||||
|
||||
/// [`Curve::parse`] interpolated as After Effects' Curves effect does: a natural cubic spline
|
||||
/// through the points, clamped to 0..1 (AE 2026: RGB points (0,0) (125,199) (255,255) and
|
||||
/// Red (0,0) (128,44) (255,255) map every grey step exactly as the natural spline does).
|
||||
pub fn parse_natural(s: &str) -> Option<Curve> {
|
||||
Self::parse_with(s, true)
|
||||
}
|
||||
|
||||
fn parse_with(s: &str, natural: bool) -> Option<Curve> {
|
||||
let mut pts: Vec<(f32, f32)> = s
|
||||
.split(|c: char| c.is_whitespace() || c == ';')
|
||||
.filter(|t| !t.is_empty())
|
||||
@@ -74,6 +85,12 @@ impl Curve {
|
||||
let ys: Vec<f32> = pts.iter().map(|p| p.1).collect();
|
||||
let n = xs.len();
|
||||
let d: Vec<f32> = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect();
|
||||
if natural {
|
||||
let ms = natural_slopes(&xs, &d);
|
||||
let mut c = Curve { xs, ys, ms, lut: Vec::new() };
|
||||
c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32).clamp(0.0, 1.0)).collect();
|
||||
return Some(c);
|
||||
}
|
||||
let mut ms = vec![0.0f32; n];
|
||||
ms[0] = d[0];
|
||||
ms[n - 1] = d[n - 2];
|
||||
@@ -136,6 +153,40 @@ impl Curve {
|
||||
}
|
||||
}
|
||||
|
||||
/// Knot slopes of the natural cubic spline (zero curvature at both ends) through points with
|
||||
/// abscissae `xs` and segment slopes `d`; as Hermite tangents they reproduce the spline exactly.
|
||||
fn natural_slopes(xs: &[f32], d: &[f32]) -> Vec<f32> {
|
||||
let n = xs.len();
|
||||
if n < 3 {
|
||||
return vec![d.first().copied().unwrap_or(0.0); n];
|
||||
}
|
||||
let h: Vec<f64> = (0..n - 1).map(|k| f64::from(xs[k + 1] - xs[k])).collect();
|
||||
let dd: Vec<f64> = d.iter().map(|&v| f64::from(v)).collect();
|
||||
// Second derivatives M[1..n-1] (M[0] = M[n-1] = 0): tridiagonal system, Thomas algorithm.
|
||||
let m = n - 2;
|
||||
let (mut a, mut b, mut c, mut r) = (vec![0.0; m], vec![0.0; m], vec![0.0; m], vec![0.0; m]);
|
||||
for i in 0..m {
|
||||
let k = i + 1;
|
||||
a[i] = h[k - 1];
|
||||
b[i] = 2.0 * (h[k - 1] + h[k]);
|
||||
c[i] = h[k];
|
||||
r[i] = 6.0 * (dd[k] - dd[k - 1]);
|
||||
}
|
||||
for i in 1..m {
|
||||
let w = a[i] / b[i - 1];
|
||||
b[i] -= w * c[i - 1];
|
||||
r[i] -= w * r[i - 1];
|
||||
}
|
||||
let mut sec = vec![0.0f64; n];
|
||||
for i in (0..m).rev() {
|
||||
let next = if i + 1 < m { sec[i + 2] } else { 0.0 };
|
||||
sec[i + 1] = (r[i] - c[i] * next) / b[i];
|
||||
}
|
||||
let mut ms: Vec<f32> = (0..n - 1).map(|k| (dd[k] - h[k] * (2.0 * sec[k] + sec[k + 1]) / 6.0) as f32).collect();
|
||||
ms.push((dd[n - 2] + h[n - 2] * (sec[n - 2] + 2.0 * sec[n - 1]) / 6.0) as f32);
|
||||
ms
|
||||
}
|
||||
|
||||
fn ev(c: &Option<Curve>, v: f32) -> f32 {
|
||||
c.as_ref().map(|c| c.eval(v)).unwrap_or(v)
|
||||
}
|
||||
@@ -145,7 +196,7 @@ fn ev(c: &Option<Curve>, v: f32) -> f32 {
|
||||
/// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the
|
||||
/// master RGB curve first, then their own curve.
|
||||
fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id)));
|
||||
let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse_natural(ctx.params.s(id)));
|
||||
if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() {
|
||||
return b;
|
||||
}
|
||||
@@ -201,7 +252,7 @@ impl Lv {
|
||||
if clip_w {
|
||||
t = t.min(1.0);
|
||||
}
|
||||
self.ob + (self.ow - self.ob) * t.max(0.0).powf(1.0 / self.g.max(0.01))
|
||||
self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +268,10 @@ fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
return b;
|
||||
}
|
||||
let clip = crate::color_fx::levels_clip(ctx);
|
||||
map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0)));
|
||||
// Each channel's own controls first, then the RGB controls: After Effects 2026 with RGB
|
||||
// (0.1, 0.9, gamma 1.5, 0.05, 0.95) and Green Input White 0.8 maps grey 32/255 to green
|
||||
// 52/255 (RGB first would give 45).
|
||||
map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[0].apply(lv[i + 1].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0)));
|
||||
b
|
||||
}
|
||||
|
||||
@@ -1139,6 +1193,86 @@ mod tests {
|
||||
assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1,
|
||||
/// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green
|
||||
/// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps:
|
||||
/// the channel controls run first, nothing clips (8-bit captures clamp the results).
|
||||
#[test]
|
||||
fn levels_ic_matches_after_effects_in_32_bpc() {
|
||||
let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
let ae = [
|
||||
[0, 0, 70],
|
||||
[0, 0, 88],
|
||||
[0, 52, 104],
|
||||
[61, 108, 132],
|
||||
[102, 150, 158],
|
||||
[138, 187, 181],
|
||||
[171, 220, 203],
|
||||
[203, 250, 223],
|
||||
[233, 255, 243],
|
||||
[255, 255, 255],
|
||||
];
|
||||
let mut img = Image::new(10, 1);
|
||||
for (x, v) in steps.iter().enumerate() {
|
||||
let v = *v as f32 / 255.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let vals = [
|
||||
("rgb/rgbInBlack", Value::Scalar(0.1)),
|
||||
("rgb/rgbInWhite", Value::Scalar(0.9)),
|
||||
("rgb/rgbGamma", Value::Scalar(1.5)),
|
||||
("rgb/rgbOutBlack", Value::Scalar(0.05)),
|
||||
("rgb/rgbOutWhite", Value::Scalar(0.95)),
|
||||
("red/redGamma", Value::Scalar(0.8)),
|
||||
("green/greenInWhite", Value::Scalar(0.8)),
|
||||
("blue/blueOutBlack", Value::Scalar(0.2)),
|
||||
];
|
||||
let s = find("ec.color.levelsic").unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in &vals {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let render = |float_depth: bool| {
|
||||
let env = crate::EffectEnv { float_depth, ..Default::default() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env };
|
||||
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
};
|
||||
let out = render(true);
|
||||
for (x, want) in ae.iter().enumerate() {
|
||||
let p = out.get(x as i64, 0);
|
||||
for c in 0..3 {
|
||||
let got = p[c].clamp(0.0, 1.0) * 255.0;
|
||||
assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]);
|
||||
}
|
||||
}
|
||||
// In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black.
|
||||
let clipped = render(false);
|
||||
assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0));
|
||||
}
|
||||
|
||||
/// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199)
|
||||
/// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results.
|
||||
#[test]
|
||||
fn curves_follow_a_natural_spline_as_in_after_effects() {
|
||||
let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
let mut img = Image::new(10, 1);
|
||||
for (x, v) in steps.iter().enumerate() {
|
||||
let v = *v as f32 / 255.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::<Vec<_>>().join(" "));
|
||||
let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]);
|
||||
let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]);
|
||||
let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255];
|
||||
let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255];
|
||||
for x in 0..10 {
|
||||
let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32;
|
||||
let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32;
|
||||
assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]);
|
||||
assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_ic_identity_and_red_only() {
|
||||
let img = ramp();
|
||||
|
||||
+266
-32
@@ -14,12 +14,27 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
|
||||
}
|
||||
|
||||
/// Rec. 601 luminance on the working-space values (sRGB working space, not linearised: red
|
||||
/// 76/255, green 150/255, blue 29/255), as After Effects 2026 renders Tint, Tritone, Luma Key and
|
||||
/// Extract. Tritone, case 718: a red solid with Blend With Original 25 gives (132, 23, 46), from
|
||||
/// luminance 0.299; Rec. 709's 0.213 gave (113, 16, 49); on a grey ramp both weightings agree
|
||||
/// (case 815). Luma Key and Extract: Key Out Darker / Extract Black Point at 65, 166 and 24 on
|
||||
/// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green
|
||||
/// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it.
|
||||
pub(crate) fn luma_601(c: [f32; 3]) -> f32 {
|
||||
LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2]
|
||||
}
|
||||
|
||||
/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU
|
||||
/// test `shader_constants_match_the_cpu` checks that they are these.
|
||||
pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114];
|
||||
|
||||
fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let black = ctx.params.color("black");
|
||||
let white = ctx.params.color("white");
|
||||
let amt = ctx.params.f("amount") as f32 / 100.0;
|
||||
b.img.map_straight(|c| {
|
||||
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
||||
let l = luma_601(c).clamp(0.0, 1.0);
|
||||
let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l];
|
||||
mix(c, t, amt)
|
||||
});
|
||||
@@ -32,7 +47,7 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let sh = ctx.params.color("shadows");
|
||||
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
|
||||
b.img.map_straight(|c| {
|
||||
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
||||
let l = luma_601(c).clamp(0.0, 1.0);
|
||||
let t = if l < 0.5 {
|
||||
mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0)
|
||||
} else {
|
||||
@@ -49,18 +64,22 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of
|
||||
/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1
|
||||
/// pass through. The GPU kernel mirrors it.
|
||||
///
|
||||
/// Negative Brightness and Contrast are the inverse curves of the positive ones (After Effects
|
||||
/// 2026 ramps: Brightness −40 is Brightness +40 inverted within 2.6/255, Contrast −50 likewise),
|
||||
/// so they use the reciprocal exponents: Brightness −40 was 20/255 off AE, now 6.2.
|
||||
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return v;
|
||||
}
|
||||
let mut v = v.max(0.0);
|
||||
if b > 0.0 {
|
||||
v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b));
|
||||
} else if b < 0.0 {
|
||||
v = v.powf(2f32.powf(-1.32 * b));
|
||||
if b != 0.0 {
|
||||
let e = 2f32.powf(1.24 * b.abs());
|
||||
v = 1.0 - (1.0 - v).powf(if b > 0.0 { e } else { 1.0 / e });
|
||||
}
|
||||
if c != 0.0 {
|
||||
let g = (1.0 + 0.53 * c).max(0.05);
|
||||
let g = (1.0 + 0.53 * c.abs()).max(0.05);
|
||||
let g = if c > 0.0 { g } else { 1.0 / g };
|
||||
v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) };
|
||||
}
|
||||
v
|
||||
@@ -197,16 +216,32 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
pub const LEVELS_CLIP: [&str; 3] = ["Off", "On", "Off for 32 bpc Color"];
|
||||
|
||||
/// Whether Levels clips below Input Black / above Input White. "Off for 32 bpc Color" clips
|
||||
/// (effects can't see the project's bit depth; 8/16 bpc behaviour). The hidden `noClip` switch
|
||||
/// of projects saved before the two popups existed turns both off.
|
||||
/// in 8 and 16 bpc projects and not in 32 bpc ones (After Effects 2026, Levels (Individual
|
||||
/// Controls) in a 32 bpc project: values below Input Black end below Output Black). The hidden
|
||||
/// `noClip` switch of projects saved before the two popups existed turns both off.
|
||||
pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) {
|
||||
if ctx.params.b("noClip") {
|
||||
return (false, false);
|
||||
}
|
||||
let on = |id: &str| ctx.params.get(id).is_none_or(|v| v.as_enum() != 0);
|
||||
let on = |id: &str| levels_clip_on(ctx, id);
|
||||
(on("clipToOutputBlack"), on("clipToOutputWhite"))
|
||||
}
|
||||
|
||||
/// One Clip To Output popup: 0 Off, 1 On, 2 Off for 32 bpc Color (missing: clip).
|
||||
pub fn levels_clip_on(ctx: &EffectCtx, id: &str) -> bool {
|
||||
match ctx.params.get(id).map(|v| v.as_enum()) {
|
||||
Some(0) => false,
|
||||
Some(2) => !ctx.env.float_depth,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels' gamma on the normalised input `t`: unclipped values below Input Black extrapolate
|
||||
/// (the curve mirrored through 0) instead of stopping at Output Black.
|
||||
pub fn levels_gamma(t: f32, g: f32) -> f32 {
|
||||
if t >= 0.0 { t.powf(1.0 / g) } else { -(-t).powf(1.0 / g) }
|
||||
}
|
||||
|
||||
/// Levels' Channel popup.
|
||||
pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"];
|
||||
|
||||
@@ -232,8 +267,7 @@ fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool))
|
||||
if clip_w {
|
||||
t = t.min(1.0);
|
||||
}
|
||||
let t = t.max(0.0).powf(1.0 / g);
|
||||
ob + (ow - ob) * t
|
||||
ob + (ow - ob) * levels_gamma(t, g)
|
||||
}
|
||||
|
||||
const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0];
|
||||
@@ -276,7 +310,7 @@ pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4]
|
||||
let ids = levels_channel_ids(i + 1);
|
||||
let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k]));
|
||||
let pre = LEVELS_CHANNEL_PREFIX[i];
|
||||
let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0);
|
||||
let on = |id: String| levels_clip_on(ctx, &id);
|
||||
let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) };
|
||||
([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip)
|
||||
})
|
||||
@@ -304,6 +338,18 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion
|
||||
/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005
|
||||
/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65).
|
||||
/// `kernels.wgsl` repeats it as a literal; the GPU test `shader_constants_match_the_cpu` checks it.
|
||||
pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2;
|
||||
|
||||
/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless
|
||||
/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve
|
||||
/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it
|
||||
/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a
|
||||
/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past
|
||||
/// black and the default settings leave every value unchanged.
|
||||
fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let s = exposure_settings(ctx);
|
||||
let bypass = ctx.params.b("bypassLinearLight");
|
||||
@@ -313,9 +359,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if bypass {
|
||||
return mirror_pow(c[i] * e + off, 1.0 / g);
|
||||
}
|
||||
let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0));
|
||||
let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA);
|
||||
let o = mirror_pow(lin * e + off, 1.0 / g);
|
||||
effectcraft_color::linear_to_srgb(o.abs()).copysign(o)
|
||||
mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA)
|
||||
})
|
||||
});
|
||||
b
|
||||
@@ -333,6 +379,37 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
|
||||
/// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted
|
||||
/// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright
|
||||
/// and black blocks (#1463), and 32-bpc overbrights divided by zero.
|
||||
/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a
|
||||
/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled
|
||||
/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid
|
||||
/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2),
|
||||
/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first.
|
||||
/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU
|
||||
/// test `shader_constants_match_the_cpu` checks that they are these.
|
||||
pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11];
|
||||
|
||||
pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] {
|
||||
let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2];
|
||||
if !gray.is_finite() || gray <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
if gray >= 1.0 {
|
||||
return [gray; 3];
|
||||
}
|
||||
let d = gray - lum(tint);
|
||||
let c = tint.map(|v| v + d);
|
||||
let l = lum(c);
|
||||
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
|
||||
let mut o = c;
|
||||
if n < 0.0 && l - n > 1e-9 {
|
||||
o = o.map(|v| l + (v - l) * l / (l - n));
|
||||
}
|
||||
if x > 1.0 && x - l > 1e-9 {
|
||||
o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")]
|
||||
.map(|v| v as f32 / 100.0);
|
||||
@@ -347,13 +424,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
|
||||
let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
|
||||
let gray = (min + (max - min) * wt).max(0.0);
|
||||
if tint_on {
|
||||
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
|
||||
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
|
||||
[r, g, bl]
|
||||
} else {
|
||||
[gray, gray, gray]
|
||||
}
|
||||
if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] }
|
||||
});
|
||||
b
|
||||
}
|
||||
@@ -418,15 +489,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
});
|
||||
return b;
|
||||
}
|
||||
// Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After
|
||||
// Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with
|
||||
// nothing of the original showing through (it is not a mix with the original).
|
||||
b.img.data.iter_mut().for_each(|px| {
|
||||
let a = if invert { 1.0 - px[3] } else { px[3] };
|
||||
let f = [c[0] * a, c[1] * a, c[2] * a];
|
||||
for i in 0..3 {
|
||||
px[i] += (f[i] - px[i]) * opacity;
|
||||
}
|
||||
if invert {
|
||||
px[3] += (a - px[3]) * opacity;
|
||||
}
|
||||
let a = if invert { 1.0 - px[3] } else { px[3] } * opacity;
|
||||
*px = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
});
|
||||
b
|
||||
}
|
||||
@@ -969,11 +1037,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
"Exposure",
|
||||
{
|
||||
let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))];
|
||||
// After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from
|
||||
// older projects or Photoshop imports (0.01..9.99) are kept and still render.
|
||||
let group = |ids: [&'static str; 3]| {
|
||||
[
|
||||
p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
|
||||
p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)),
|
||||
p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)),
|
||||
p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)),
|
||||
p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)),
|
||||
]
|
||||
};
|
||||
v.extend(group(["master/exposure", "master/offset", "master/gamma"]));
|
||||
@@ -1173,6 +1243,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working
|
||||
/// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values.
|
||||
#[test]
|
||||
fn tint_defaults_match_after_effects_greys() {
|
||||
for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
|
||||
let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]);
|
||||
let want = want / 255.0;
|
||||
assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_reads_get_phase_from() {
|
||||
// Same intensity, different red: Intensity gives one colour, Red gives two.
|
||||
@@ -1227,6 +1308,75 @@ mod tests {
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
/// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows
|
||||
/// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46);
|
||||
/// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64),
|
||||
/// (153, 51, 51), (204, 153, 128).
|
||||
#[test]
|
||||
fn tritone_matches_after_effects() {
|
||||
let colours = [
|
||||
("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])),
|
||||
("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])),
|
||||
("shadows", Value::Color([0.0, 0.0, 0.3, 1.0])),
|
||||
];
|
||||
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0);
|
||||
let mut red = colours.to_vec();
|
||||
red.push(("blend", num(25.0)));
|
||||
let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] {
|
||||
let v = level / 255.0;
|
||||
let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]);
|
||||
assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow
|
||||
/// and white solids at the default sliders (case 825).
|
||||
#[test]
|
||||
fn black_white_tint_matches_after_effects() {
|
||||
let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))];
|
||||
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5);
|
||||
let mut reds = tint.to_vec();
|
||||
reds.push(("reds", num(10.0)));
|
||||
let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]);
|
||||
assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]);
|
||||
assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
// Hostile grays stay finite.
|
||||
for g in [f32::NAN, -1.0, 0.0, 4.0] {
|
||||
assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast
|
||||
/// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit)
|
||||
#[test]
|
||||
fn negative_brightness_and_contrast_invert_the_positive_curves() {
|
||||
let go = |b: f64, c: f64, x: u8| {
|
||||
let v = x as f32 / 255.0;
|
||||
(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32
|
||||
};
|
||||
let samples: &[(f64, f64, u8, u8)] = &[
|
||||
(-40.0, 0.0, 64, 50),
|
||||
(-40.0, 0.0, 128, 99),
|
||||
(-40.0, 0.0, 191, 155),
|
||||
(-40.0, 0.0, 234, 207),
|
||||
(0.0, -50.0, 21, 28),
|
||||
(0.0, -50.0, 64, 76),
|
||||
(0.0, -50.0, 191, 179),
|
||||
(0.0, -50.0, 234, 227),
|
||||
(40.0, 0.0, 128, 162),
|
||||
(0.0, 50.0, 64, 52),
|
||||
];
|
||||
for &(b, c, x, ae) in samples {
|
||||
let got = go(b, c, x);
|
||||
assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
|
||||
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
|
||||
/// colour stays finite (it used to divide by zero).
|
||||
@@ -1321,6 +1471,66 @@ mod tests {
|
||||
assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}");
|
||||
}
|
||||
|
||||
/// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma
|
||||
/// of 0.05, valid before, as in Photoshop) survives loading and still renders.
|
||||
#[test]
|
||||
fn exposure_has_after_effects_ranges_and_keeps_old_values() {
|
||||
use effectcraft_project::build::Ids;
|
||||
let spec = crate::find("ec.color.exposure").unwrap();
|
||||
let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)];
|
||||
let sliders: Vec<_> = spec
|
||||
.params
|
||||
.iter()
|
||||
.filter_map(|p| match p.ui {
|
||||
ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sliders.len(), 12);
|
||||
for (i, (id, r)) in sliders.iter().enumerate() {
|
||||
assert_eq!(*r, want[i % 3], "{id}");
|
||||
}
|
||||
let mut next = 1;
|
||||
let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
|
||||
let gamma = g.prop_mut("master/gamma").unwrap();
|
||||
gamma.value = num(0.05);
|
||||
gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2);
|
||||
crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
|
||||
let gamma = g.prop_mut("master/gamma").unwrap();
|
||||
assert_eq!(gamma.value, num(0.05));
|
||||
assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1));
|
||||
let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]);
|
||||
assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}");
|
||||
}
|
||||
|
||||
/// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space,
|
||||
/// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power.
|
||||
/// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets.
|
||||
#[test]
|
||||
fn exposure_matches_after_effects_gamma_2_2() {
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
let code = |o: [f32; 4], i: usize| o[i] * 255.0;
|
||||
// Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture).
|
||||
for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] {
|
||||
let o = px("ec.color.exposure", &[("master/offset", num(offset))], red);
|
||||
for i in [1, 2] {
|
||||
assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i));
|
||||
}
|
||||
assert!(o[0] >= 1.0, "{o:?}");
|
||||
}
|
||||
// Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54.
|
||||
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red);
|
||||
assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
|
||||
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red);
|
||||
assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
|
||||
// Default settings leave 32-bpc values alone, negative and over-range ones included.
|
||||
let hdr = [-0.25, 0.5, 2.0, 1.0];
|
||||
assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5));
|
||||
// A negative Offset pushes black below zero, mirrored, rather than clipping or NaN.
|
||||
let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]);
|
||||
assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_to_color_change_modes_tolerances_and_matte() {
|
||||
let red = [0.8, 0.1, 0.1, 1.0];
|
||||
@@ -1462,6 +1672,30 @@ mod tests {
|
||||
assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp
|
||||
/// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the
|
||||
/// fill colour at half the layer's alpha, nothing of the ramp left.
|
||||
#[test]
|
||||
fn fill_opacity_is_the_layers_opacity_as_in_after_effects() {
|
||||
let mut img = Image::new(16, 4);
|
||||
for x in 0..16 {
|
||||
let v = x as f32 / 15.0;
|
||||
for y in 0..4 {
|
||||
img.set(x, y, [v, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))];
|
||||
let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img;
|
||||
for x in [0, 7, 15] {
|
||||
let p = out.get(x, 2);
|
||||
let ae = [0.0, 0.251, 0.502, 0.502];
|
||||
assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}");
|
||||
}
|
||||
// A half-transparent pixel ends at a quarter alpha, still the fill colour.
|
||||
let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img;
|
||||
assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_palettes_modify_selection_masking_and_add_phase() {
|
||||
let grey = [0.5, 0.5, 0.5, 1.0];
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Editing a Cryptomatte effect's Selection text from a pick in the viewer (#473): replace it
|
||||
//! with the picked ID, add it, or remove it, in the grammar `selection_hashes` reads.
|
||||
|
||||
use crate::channel3d::{selection_hashes, selection_names};
|
||||
|
||||
/// How a pick changes the selection.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PickMode {
|
||||
Replace,
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// A name as a selection token: quoted (with `\` escapes) when it has a comma, a quote or a
|
||||
/// backslash, which the selection parser would otherwise split on or read as syntax.
|
||||
fn quote(name: &str) -> String {
|
||||
if !name.contains([',', '"', '\'', '\\']) {
|
||||
return name.to_string();
|
||||
}
|
||||
let mut out = String::from("\"");
|
||||
for c in name.chars() {
|
||||
if c == '"' || c == '\\' {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// The selection token for the ID `hash` named `name` in `manifest`: the (quoted) name when it
|
||||
/// reads back as exactly that ID, else `<hex>`. That covers unnamed IDs, names with `*` (which
|
||||
/// would select by wildcard), names that look like `<hex>`, names with surrounding spaces (the
|
||||
/// parser trims them) and names shared by several IDs.
|
||||
pub fn token_for(name: Option<&str>, hash: u32, manifest: Option<&[(String, u32)]>) -> String {
|
||||
match name {
|
||||
Some(n) if !n.is_empty() && !n.contains('*') && selection_hashes("e(n), manifest) == [hash] => quote(n),
|
||||
_ => format!("<{hash:08x}>"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `sel` edited by a pick of the ID `hash` whose token is `token` (see [`token_for`]). The text
|
||||
/// stays as typed when the edit wouldn't change what it selects: Replace when it selects just
|
||||
/// that ID, Add when it already selects it, Remove when no name or `<hex>` token selects it (a
|
||||
/// wildcard that matches it stays). Other tokens (wildcards included) keep their text,
|
||||
/// re-quoted where needed, separated by `, `.
|
||||
pub fn edit_selection(sel: &str, token: &str, hash: u32, mode: PickMode, manifest: Option<&[(String, u32)]>) -> String {
|
||||
let tokens: Vec<String> = selection_names(sel).into_iter().filter(|t| !t.is_empty()).collect();
|
||||
let selects = |t: &String| !t.contains('*') && selection_hashes("e(t), manifest).contains(&hash);
|
||||
let kept: Vec<String> = match mode {
|
||||
PickMode::Replace if selection_hashes(sel, manifest) == [hash] => return sel.to_string(),
|
||||
PickMode::Replace => return token.to_string(),
|
||||
PickMode::Add if selection_hashes(sel, manifest).contains(&hash) => return sel.to_string(),
|
||||
PickMode::Add => tokens.iter().map(|t| quote(t)).chain([token.to_string()]).collect(),
|
||||
PickMode::Remove if !tokens.iter().any(selects) => return sel.to_string(),
|
||||
PickMode::Remove => tokens.iter().filter(|t| !selects(t)).map(|t| quote(t)).collect(),
|
||||
};
|
||||
kept.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_raster::channels3d::crypto_hash;
|
||||
|
||||
fn manifest() -> Vec<(String, u32)> {
|
||||
["bunny", "Green, Glossy", r#"say "hi""#, r"back\slash", " pad ", "star*", "<0000abcd>", "twin"]
|
||||
.iter()
|
||||
.map(|n| (n.to_string(), crypto_hash(n)))
|
||||
.chain([("twin".to_string(), 7)])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every name's token reads back as exactly its ID, through the effect's own parser.
|
||||
#[test]
|
||||
fn tokens_round_trip_through_selection_hashes() {
|
||||
let m = manifest();
|
||||
for (n, h) in &m {
|
||||
let t = token_for(Some(n), *h, Some(&m));
|
||||
assert_eq!(selection_hashes(&t, Some(&m)), [*h], "{n:?} → {t:?}");
|
||||
}
|
||||
assert_eq!(token_for(Some("bunny"), crypto_hash("bunny"), Some(&m)), "bunny");
|
||||
assert_eq!(token_for(Some("Green, Glossy"), crypto_hash("Green, Glossy"), Some(&m)), r#""Green, Glossy""#);
|
||||
assert_eq!(token_for(Some(r#"say "hi""#), crypto_hash(r#"say "hi""#), Some(&m)), r#""say \"hi\"""#);
|
||||
// Wildcard-looking, hash-looking, empty, unnamed and shared names: the hash.
|
||||
assert_eq!(token_for(Some("star*"), crypto_hash("star*"), Some(&m)), format!("<{:08x}>", crypto_hash("star*")));
|
||||
assert_eq!(token_for(Some("<0000abcd>"), crypto_hash("<0000abcd>"), Some(&m)), format!("<{:08x}>", crypto_hash("<0000abcd>")));
|
||||
assert_eq!(token_for(Some(""), 5, Some(&m)), "<00000005>");
|
||||
assert_eq!(token_for(Some(" pad "), crypto_hash(" pad "), Some(&m)), format!("<{:08x}>", crypto_hash(" pad ")));
|
||||
assert_eq!(token_for(None, 0xdead_beef, None), "<deadbeef>");
|
||||
assert_eq!(token_for(Some("twin"), 7, Some(&m)), "<00000007>", "the name selects the other twin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_remove_keep_other_tokens_and_skip_duplicates() {
|
||||
let m = manifest();
|
||||
let (b, g) = (crypto_hash("bunny"), crypto_hash("Green, Glossy"));
|
||||
let tok = |n: &str| token_for(Some(n), crypto_hash(n), Some(&m));
|
||||
let edit = |sel: &str, n: &str, mode| edit_selection(sel, &tok(n), crypto_hash(n), mode, Some(&m));
|
||||
assert_eq!(edit("anything, else", "bunny", PickMode::Replace), "bunny");
|
||||
assert_eq!(edit(" bunny ", "bunny", PickMode::Replace), " bunny ", "same meaning: unchanged");
|
||||
assert_eq!(edit("", "bunny", PickMode::Add), "bunny");
|
||||
assert_eq!(edit("bunny", "Green, Glossy", PickMode::Add), r#"bunny, "Green, Glossy""#);
|
||||
// Already selected: by name, by hash, by a wildcard. The text stays as typed.
|
||||
assert_eq!(edit("bunny,floor", "bunny", PickMode::Add), "bunny,floor");
|
||||
assert_eq!(edit(&format!("<{b:08x}>"), "bunny", PickMode::Add), format!("<{b:08x}>"));
|
||||
assert_eq!(edit("bun*", "bunny", PickMode::Add), "bun*");
|
||||
// Other tokens keep their meaning: quoted where needed, wildcards stay wildcards.
|
||||
assert_eq!(edit(r#"'a, b', fl*, "x, *""#, "bunny", PickMode::Add), r#""a, b", fl*, "x, *", bunny"#);
|
||||
assert_eq!(edit(r#""Green, Glossy", bunny"#, "bunny", PickMode::Remove), r#""Green, Glossy""#);
|
||||
assert_eq!(edit(&format!("bunny, <{b:08x}>, x, bunny"), "bunny", PickMode::Remove), "x");
|
||||
assert_eq!(edit(&format!("<{g:08x}>"), "Green, Glossy", PickMode::Remove), "");
|
||||
// Not selected, or only by a wildcard: unchanged.
|
||||
assert_eq!(edit("a ,b", "bunny", PickMode::Remove), "a ,b");
|
||||
assert_eq!(edit("bun*, x", "bunny", PickMode::Remove), "bun*, x");
|
||||
for sel in [r#""a, b", fl*, bunny"#, r#"x, "Green, Glossy", "say \"hi\"", "back\\slash""#] {
|
||||
let before = selection_hashes(sel, Some(&m));
|
||||
let after = selection_hashes(&edit(sel, " pad ", PickMode::Add), Some(&m));
|
||||
assert_eq!(after, [before, vec![crypto_hash(" pad ")]].concat(), "{sel}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
-28
@@ -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 {
|
||||
|
||||
@@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px {
|
||||
}
|
||||
}
|
||||
|
||||
/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point
|
||||
/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100
|
||||
/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error
|
||||
/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0.
|
||||
pub fn four_color_softening(blend: f64, scale: f64) -> f64 {
|
||||
let b = blend.max(0.0) * scale;
|
||||
(b * b).max(1e-6)
|
||||
}
|
||||
|
||||
fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pts: Vec<((f64, f64), [f32; 4])> =
|
||||
(1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect();
|
||||
let blend = ctx.params.f("blend").max(1.0);
|
||||
let k = four_color_softening(ctx.params.f("blend"), b.scale);
|
||||
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let op = ctx.params.f("opacity") as f32 / 100.0;
|
||||
let mode = ctx.params.e("blendingMode");
|
||||
@@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let mut wsum = 0.0;
|
||||
for ((qx, qy), c) in &pts {
|
||||
let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2);
|
||||
let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5);
|
||||
let w = 1.0 / (d2 + k);
|
||||
for i in 0..3 {
|
||||
acc[i] += c[i] as f64 * w;
|
||||
}
|
||||
@@ -423,6 +432,59 @@ mod generator_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 %
|
||||
/// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10
|
||||
/// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5).
|
||||
#[test]
|
||||
fn four_color_blend_matches_after_effects() {
|
||||
let pts = [
|
||||
("positionsColors/point1", [19.2, 10.8]),
|
||||
("positionsColors/point2", [172.8, 10.8]),
|
||||
("positionsColors/point3", [19.2, 97.2]),
|
||||
("positionsColors/point4", [172.8, 97.2]),
|
||||
];
|
||||
let cols = [
|
||||
("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]),
|
||||
("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]),
|
||||
("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]),
|
||||
("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]),
|
||||
];
|
||||
let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [
|
||||
(
|
||||
10.0,
|
||||
[
|
||||
(30, 20, [249.0, 240.0, 15.0]),
|
||||
(150, 30, [20.0, 208.0, 47.0]),
|
||||
(60, 90, [213.0, 56.0, 199.0]),
|
||||
(100, 10, [115.0, 177.0, 78.0]),
|
||||
(10, 50, [238.0, 145.0, 110.0]),
|
||||
],
|
||||
),
|
||||
(
|
||||
30.0,
|
||||
[
|
||||
(30, 20, [236.0, 214.0, 41.0]),
|
||||
(150, 30, [30.0, 189.0, 66.0]),
|
||||
(60, 90, [203.0, 67.0, 188.0]),
|
||||
(100, 10, [117.0, 173.0, 82.0]),
|
||||
(10, 50, [233.0, 140.0, 115.0]),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (blend, samples) in ae {
|
||||
let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect();
|
||||
vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v))));
|
||||
vals.push(("blend", num(blend)));
|
||||
let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default());
|
||||
for (x, y, want) in samples {
|
||||
let p = o.img.get(x, y);
|
||||
for c in 0..3 {
|
||||
assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_color_none_stays_inside_the_layer_alpha() {
|
||||
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
//! matte clean-up. Written from public behaviour descriptions and the standard colour-difference /
|
||||
//! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting").
|
||||
|
||||
use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear};
|
||||
use effectcraft_color::{rgb_to_hsl, srgb_to_linear};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, gaussian_blur};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::color_fx::luma_601;
|
||||
use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
@@ -135,46 +136,23 @@ fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
// ---- Luma Key ----
|
||||
|
||||
/// Luma Key as After Effects 2026 keys a grey ramp (cases 812-814, 719): Key Out Brighter and
|
||||
/// Darker cut hard at Threshold and ignore Tolerance; Key Out Similar removes, and Dissimilar
|
||||
/// keeps, a hard band of Threshold ± Tolerance (levels of 255). Edge Thin and Edge Feather
|
||||
/// then soften the cut.
|
||||
fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let kind = ctx.params.e("keyType");
|
||||
let thr = ctx.params.f("threshold") as f32 / 255.0;
|
||||
let tol = ctx.params.f("tolerance") as f32 / 255.0;
|
||||
let m = matte_of(&b.img, |c, _| {
|
||||
let l = luminance(c[0], c[1], c[2]);
|
||||
match kind {
|
||||
0 => {
|
||||
if tol > 0.0 {
|
||||
((thr + tol - l) / tol).clamp(0.0, 1.0)
|
||||
} else if l > thr {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if (l - thr).abs() <= tol {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
if (l - thr).abs() <= tol {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if tol > 0.0 {
|
||||
((l - (thr - tol)) / tol).clamp(0.0, 1.0)
|
||||
} else if l < thr {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
let l = luma_601([c[0], c[1], c[2]]);
|
||||
let keep = match kind {
|
||||
0 => l <= thr,
|
||||
2 => (l - thr).abs() > tol,
|
||||
3 => (l - thr).abs() <= tol,
|
||||
_ => l >= thr,
|
||||
};
|
||||
if keep { 1.0 } else { 0.0 }
|
||||
});
|
||||
let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale);
|
||||
apply_matte(&mut b.img, &m, 0);
|
||||
@@ -334,7 +312,7 @@ fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
2 => c[1],
|
||||
3 => c[2],
|
||||
4 => a,
|
||||
_ => luminance(c[0], c[1], c[2]),
|
||||
_ => luma_601([c[0], c[1], c[2]]),
|
||||
};
|
||||
let mut k = 1.0f32;
|
||||
if v < bp {
|
||||
@@ -802,6 +780,68 @@ mod tests {
|
||||
assert_eq!(out.get(1, 0)[3], 1.0);
|
||||
}
|
||||
|
||||
/// After Effects 2026 (cases 812-814): Luma Key on a vertical black-to-white ramp (row y
|
||||
/// has level (y + 0.5) / 1080 × 255), Threshold 140, Tolerance 20. EffectCraft ramped the
|
||||
/// matte over the tolerance (case 719: a 50 % grey kept 80 % of its alpha where AE keys it
|
||||
/// out).
|
||||
#[test]
|
||||
fn luma_key_matches_after_effects() {
|
||||
let ramp = || {
|
||||
let mut img = Image::new(1, 1080);
|
||||
for y in 0..1080 {
|
||||
let v = (y as f32 + 0.5) / 1080.0;
|
||||
img.set(0, y, [v, v, v, 1.0]);
|
||||
}
|
||||
img
|
||||
};
|
||||
let key = |kind: u32, thin: f64, feather: f64| {
|
||||
let set =
|
||||
[("keyType", Value::Enum(kind)), ("threshold", num(140.0)), ("tolerance", num(20.0)), ("edgeThin", num(thin)), ("edgeFeather", num(feather))];
|
||||
let o = run("ec.key.luma", &set, ramp());
|
||||
(0..1080).map(|y| o.get(0, y)[3]).collect::<Vec<f32>>()
|
||||
};
|
||||
// Key Out Darker (AE 2 = EffectCraft 1): keyed up to row 592, kept from 593.
|
||||
let d = key(1, 0.0, 0.0);
|
||||
assert!(d[..593].iter().all(|a| *a == 0.0) && d[593..].iter().all(|a| *a == 1.0), "{:?}", &d[588..598]);
|
||||
// Key Out Similar: AE keys rows 509..=677 (levels 120.3..160.0) and keeps the rest; the
|
||||
// rows at the band's ends sit within 0.1 level of 120 / 160, so they may go either way.
|
||||
let s = key(2, 0.0, 0.0);
|
||||
assert!(
|
||||
(0..1080).filter(|y| !(507..=510).contains(y) && !(676..=679).contains(y)).all(|y| (s[y] == 0.0) == (509..=677).contains(&y)),
|
||||
"{:?} {:?}",
|
||||
&s[505..512],
|
||||
&s[674..681]
|
||||
);
|
||||
// Key Out Brighter and Dissimilar are the complements.
|
||||
let b = key(0, 0.0, 0.0);
|
||||
assert!(b[595] == 0.0 && b[590] == 1.0);
|
||||
let ds = key(3, 0.0, 0.0);
|
||||
assert!(ds[600] == 1.0 && ds[400] == 0.0 && ds[700] == 0.0);
|
||||
// A 50 % grey at Darker 140 / 20 is keyed out entirely (case 719).
|
||||
let o =
|
||||
run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(140.0)), ("tolerance", num(20.0))], Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]));
|
||||
assert_eq!(o.get(0, 0)[3], 0.0);
|
||||
// Edge Thin 1, Edge Feather 2.5 (case 814): AE's alpha rises over rows 591..596.
|
||||
let f = key(1, 1.0, 2.5);
|
||||
for (y, ae) in [(590, 0.0), (591, 0.026), (592, 0.139), (593, 0.357), (594, 0.635), (595, 0.868), (596, 0.97), (597, 1.0)] {
|
||||
assert!((f[y] - ae).abs() < 0.2, "row {y}: {} vs AE {ae}", f[y]);
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026: Key Out Darker and Extract's Black Point at 65 / 166 / 24 on pure
|
||||
/// red / green / blue keep red and blue and key out green: Rec. 601 luminance (Rec. 709
|
||||
/// would key out red and blue and keep green).
|
||||
#[test]
|
||||
fn luma_key_and_extract_use_rec601_as_after_effects() {
|
||||
for (c, thr, kept) in [([1.0, 0.0, 0.0], 65.0, true), ([0.0, 1.0, 0.0], 166.0, false), ([0.0, 0.0, 1.0], 24.0, true)] {
|
||||
let img = Image::filled(2, 1, [c[0], c[1], c[2], 1.0]);
|
||||
let key = run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(thr))], img.clone());
|
||||
let ext = run("ec.key.extract", &[("blackPoint", num(thr))], img);
|
||||
let want = if kept { 1.0 } else { 0.0 };
|
||||
assert_eq!((key.get(0, 0)[3], ext.get(0, 0)[3]), (want, want), "{c:?} at {thr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_keeps_range() {
|
||||
let mut img = Image::new(3, 1);
|
||||
|
||||
@@ -21,6 +21,7 @@ mod color2;
|
||||
mod color3;
|
||||
mod color_fx;
|
||||
pub mod controls;
|
||||
pub mod crypto_select;
|
||||
mod distort;
|
||||
mod distort2;
|
||||
mod distort3;
|
||||
@@ -70,11 +71,11 @@ use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use card3d::{CompLight, CompScene};
|
||||
pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette};
|
||||
pub use color_fx::{
|
||||
HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity,
|
||||
levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip,
|
||||
BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, LUMA_601, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings,
|
||||
fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip,
|
||||
};
|
||||
pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette};
|
||||
pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts};
|
||||
pub use color3::OffsetCurve;
|
||||
pub use distort::transform_shutter;
|
||||
@@ -90,8 +91,8 @@ 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 generate::gen_mode;
|
||||
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};
|
||||
pub use generate3::{
|
||||
@@ -110,7 +111,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time,
|
||||
// effectcraft-gpu fx_tone.
|
||||
pub use ocio::color_stabilizer_maps;
|
||||
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
|
||||
pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, id_color, selection_hashes};
|
||||
pub use channel3d::{CRYPTO_LAYERS, 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::{
|
||||
@@ -297,6 +298,43 @@ impl Buf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`:
|
||||
/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its
|
||||
/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp
|
||||
/// origin in the buffer below (After Effects; a Transform after it brings the tiles into view,
|
||||
/// #1727). An adjustment at 100 % keeps the buffer it was given; one whose frame would pass
|
||||
/// 16 384 pixels a side does too.
|
||||
pub fn motion_tile_frame(ctx: &EffectCtx, offset: [f64; 2], size: (u32, u32), scale: f64) -> (u32, u32, [f64; 2]) {
|
||||
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
|
||||
let (pw, ph) = (ctx.params.f("outputWidth"), ctx.params.f("outputHeight"));
|
||||
let ow = (lw * pw / 100.0).max(1.0 / scale);
|
||||
let oh = (lh * ph / 100.0).max(1.0 / scale);
|
||||
let (nw, nh) = ((ow * scale).round().max(1.0), (oh * scale).round().max(1.0));
|
||||
let grow = [(ow - lw) * 0.5 * scale, (oh - lh) * 0.5 * scale];
|
||||
if !ctx.adjustment {
|
||||
return (nw as u32, nh as u32, grow);
|
||||
}
|
||||
// The grown rectangle's top-left in the buffer below, on whole pixels so the result lines
|
||||
// up with the comp again when the stack ends.
|
||||
let (x0, y0) = ((offset[0] - grow[0]).round(), (offset[1] - grow[1]).round());
|
||||
let fits = [x0, y0, nw, nh].iter().all(|v| v.is_finite()) && nw <= 16384.0 && nh <= 16384.0;
|
||||
if (pw == 100.0 && ph == 100.0) || !fits {
|
||||
return (size.0, size.1, offset);
|
||||
}
|
||||
(nw as u32, nh as u32, [offset[0] - x0, offset[1] - y0])
|
||||
}
|
||||
|
||||
/// Where an adjustment stack's result (comp origin at `offset`, `size` pixels) lies on the canvas
|
||||
/// it came from (comp origin at `canvas_offset`): the result pixel at canvas pixel (0, 0).
|
||||
/// `None` when it already is the canvas, frame for frame.
|
||||
pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u32), canvas: (u32, u32)) -> Option<(i64, i64)> {
|
||||
let (dx, dy) = ((offset[0] - canvas_offset[0]).round(), (offset[1] - canvas_offset[1]).round());
|
||||
if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0 && size == canvas) {
|
||||
return None;
|
||||
}
|
||||
Some((dx as i64, dy as i64))
|
||||
}
|
||||
|
||||
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
|
||||
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
|
||||
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
|
||||
@@ -406,6 +444,8 @@ pub struct EffectEnv<'a> {
|
||||
pub working_space: Option<effectcraft_color::ColorSpace>,
|
||||
/// Working-space pixels are linear light (Linearize Working Space).
|
||||
pub working_linear: bool,
|
||||
/// The project works in 32 bpc float (Levels' "Off for 32 bpc Color" clips only below it).
|
||||
pub float_depth: bool,
|
||||
/// The composition's shutter (angle and phase in degrees, samples per frame) when both the
|
||||
/// comp's and the layer's motion blur switches are on (Transform's Use Composition's
|
||||
/// Shutter Angle).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -17,7 +17,7 @@ use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::generate::{fade, lattice};
|
||||
use crate::generate::lattice;
|
||||
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider};
|
||||
|
||||
/// Fractal Type options (shared by both effects).
|
||||
@@ -139,7 +139,7 @@ fn bspline(t: f32) -> [f32; 3] {
|
||||
}
|
||||
|
||||
/// 3D value noise in about 0..1 with the given [`NOISE_TYPES`] interpolation in x and y (the
|
||||
/// evolution axis z always fades smoothly).
|
||||
/// evolution axis z always interpolates linearly, so evolution moves at a constant rate).
|
||||
///
|
||||
/// Soft Linear is linear interpolation softened by a one-cell box filter, which is the quadratic
|
||||
/// B-spline: smooth like Spline from 9 lattice values instead of 16. A fade between two values
|
||||
@@ -149,7 +149,7 @@ pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 {
|
||||
// Soft Linear's taps are centred on the nearest lattice point, the others start at the cell's.
|
||||
let h = if noise_type == NOISE_SOFT_LINEAR { 0.5 } else { 0.0 };
|
||||
let (x0, y0, z0) = ((x + h).floor(), (y + h).floor(), z.floor());
|
||||
let (tx, ty, fz) = (x + h - x0, y + h - y0, fade(z - z0));
|
||||
let (tx, ty, fz) = (x + h - x0, y + h - y0, z - z0);
|
||||
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
|
||||
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
|
||||
let slice = |dz: i32| -> f32 {
|
||||
@@ -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() {
|
||||
@@ -476,6 +478,20 @@ mod tests {
|
||||
run_fx(id, vals, Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]), 0.0, EffectEnv::default()).img
|
||||
}
|
||||
|
||||
/// Evolution moves the noise at a constant rate: no ease in or out within a revolution.
|
||||
#[test]
|
||||
fn evolution_is_linear() {
|
||||
for noise_type in [0, 1, 2, 3] {
|
||||
let a = typed_noise(0.3, 0.7, 0.0, 5, noise_type);
|
||||
let b = typed_noise(0.3, 0.7, 1.0, 5, noise_type);
|
||||
for z in [0.25, 0.5, 0.75] {
|
||||
let want = a + (b - a) * z;
|
||||
let got = typed_noise(0.3, 0.7, z, 5, noise_type);
|
||||
assert!((got - want).abs() < 1e-4, "type {noise_type} z {z}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Fractal Noise saved before the twirl-downs existed: flat ids move into their groups and
|
||||
/// the new controls take values that keep the old look.
|
||||
#[test]
|
||||
@@ -665,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));
|
||||
}
|
||||
}
|
||||
@@ -284,9 +284,13 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w:
|
||||
return None;
|
||||
}
|
||||
let fr = fc.frames.get(&f)?;
|
||||
let a = rle::decode(&fr.a)?;
|
||||
let [fw, fh] = fc.size;
|
||||
if fw == 0 || fh == 0 || a.len() != fw.checked_mul(fh)? {
|
||||
let expected = fw.checked_mul(fh)?;
|
||||
if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let a = rle::decode_bounded(&fr.a, expected)?;
|
||||
if a.len() != expected {
|
||||
return None;
|
||||
}
|
||||
let af: Vec<f32> = a.iter().map(|v| *v as f32 / 255.0).collect();
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -560,10 +560,14 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc<Image>, scale: f64) -> Option<(V
|
||||
&& fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps())
|
||||
&& let Some(fr) = fc.frames.get(&f)
|
||||
{
|
||||
let a = rle::decode(&fr.a)?;
|
||||
let bnd = rle::decode(&fr.b)?;
|
||||
let [fw, fh] = fc.size;
|
||||
if a.len() == fw * fh && bnd.len() == fw * fh {
|
||||
let expected = fw.checked_mul(fh)?;
|
||||
if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let a = rle::decode_bounded(&fr.a, expected)?;
|
||||
let bnd = rle::decode_bounded(&fr.b, expected)?;
|
||||
if a.len() == expected && bnd.len() == expected {
|
||||
let af: Vec<f32> = a.iter().map(|v| *v as f32 / 255.0).collect();
|
||||
let (alpha, band) = if fw == w && fh == h {
|
||||
(af, bnd)
|
||||
|
||||
@@ -433,13 +433,7 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let mirror = ctx.params.b("mirrorEdges");
|
||||
let phase = ctx.params.f("phase") / 360.0;
|
||||
let hshift = ctx.params.b("horizontalPhaseShift");
|
||||
let (nw, nh, noff) = if ctx.adjustment {
|
||||
(b.img.width, b.img.height, b.offset)
|
||||
} else {
|
||||
let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s);
|
||||
let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s);
|
||||
((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s])
|
||||
};
|
||||
let (nw, nh, noff) = crate::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s);
|
||||
let mut out = Image::new(nw, nh);
|
||||
let src = &b.img;
|
||||
out.rows_mut().for_each(|(y, row)| {
|
||||
|
||||
@@ -288,20 +288,39 @@ fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook {
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
|
||||
|
||||
/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday).
|
||||
/// `num_days_from_ce` of 2000-01-01: the day count since 2000-01-01 (Numbers' date epoch, a
|
||||
/// Saturday) rebased so that chrono's `num_days_from_ce` (0001-01-01 is day 1) can convert it.
|
||||
const CE_FROM_2000: i64 = 730_120;
|
||||
|
||||
/// Days in one 400-year Gregorian cycle; the proleptic calendar repeats after it.
|
||||
const DAYS_PER_400_YEARS: i64 = 146_097;
|
||||
|
||||
/// Proleptic Gregorian date (year, month, day, Saturday-indexed weekday) from a day count since
|
||||
/// 2000-01-01 (a Saturday), for any `i64` count.
|
||||
///
|
||||
/// chrono's `NaiveDate` stops near ±262143, but Numbers' `value` slider is a plain scalar with
|
||||
/// no range guard, so a keyframed count can run past that. The calendar repeats every 400 years,
|
||||
/// so chrono converts the day within one cycle and the whole cycles are added back as years (via
|
||||
/// i128 so `i64::MIN`/`MAX` can't overflow); the result matches the pre-chrono count for every
|
||||
/// year. The weekday comes from the same resolved date, so it can never disagree with it.
|
||||
fn date_from_days(days: i64) -> (i64, u32, u32, usize) {
|
||||
// Days since 0000-03-01 based civil calendar conversion.
|
||||
let z = days + 730_425; // 2000-01-01 relative to 0000-03-01
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d, days.rem_euclid(7) as usize)
|
||||
use chrono::Datelike;
|
||||
let from_ce = i128::from(days) + i128::from(CE_FROM_2000);
|
||||
let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS)));
|
||||
// `rest` is 0..146_097, so it is always a valid `num_days_from_ce` day inside chrono's range.
|
||||
let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
// Unreachable fallback: every `rest` in 0..146_097 converts. 2000-01-01 keeps the epoch's date.
|
||||
let (y, m, d, wd) = match date {
|
||||
Some(date) => {
|
||||
let year = i128::from(date.year()) + cycles * 400;
|
||||
let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });
|
||||
// DAYS is Saturday-indexed; chrono counts from Sunday.
|
||||
let wd = (date.weekday().num_days_from_sunday() as usize + 1) % 7;
|
||||
(year, date.month(), date.day(), wd)
|
||||
}
|
||||
None => (2000, 1, 1, 0),
|
||||
};
|
||||
(y, m, d, wd)
|
||||
}
|
||||
|
||||
fn timecode(frames: i64, fps: f64) -> String {
|
||||
@@ -1123,6 +1142,10 @@ mod tests {
|
||||
assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF");
|
||||
assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00");
|
||||
assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000");
|
||||
// A keyframed date count past chrono's ±262143 range still renders its real date and a
|
||||
// matching weekday (275790-09-13 is a Monday), not an epoch-derived stand-in.
|
||||
assert_eq!(txt(&[("value", num(100_000_000.0)), ("type", Value::Enum(8))], 0.0), "Monday, September 13, 275790");
|
||||
assert_eq!(txt(&[("value", num(-100_000_000.0)), ("type", Value::Enum(7))], 0.0), "Apr 20, -271791");
|
||||
assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0");
|
||||
assert_ne!(
|
||||
txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0),
|
||||
@@ -1130,6 +1153,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_from_days_past_chrono_range() {
|
||||
// chrono's NaiveDate stops near ±262143, but Numbers' `value` has no range guard, so a
|
||||
// keyframed day count can run past it. The 400-year cycle keeps the real date (and a
|
||||
// matching weekday) for every count; values from the pre-chrono formula, both directions,
|
||||
// both sides of the 2000-01-01 epoch.
|
||||
for (days, date, day) in [
|
||||
(0_i64, (2000, 1, 1), "Saturday"),
|
||||
(60, (2000, 3, 1), "Wednesday"),
|
||||
(95_015_644, (262143, 12, 31), "Tuesday"), // chrono's last representable date
|
||||
(100_000_000, (275790, 9, 13), "Monday"), // past it: used to read as the epoch
|
||||
(-96_476_616, (-262145, 12, 31), "Monday"),
|
||||
(-100_000_000, (-271791, 4, 20), "Thursday"),
|
||||
] {
|
||||
let (y, m, d, wd) = super::date_from_days(days);
|
||||
assert_eq!((y, m, d), date, "days={days}");
|
||||
assert_eq!(super::DAYS[wd], day, "days={days}");
|
||||
}
|
||||
// Extremes don't overflow (i128 intermediates).
|
||||
let _ = (super::date_from_days(i64::MAX), super::date_from_days(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_draws_glyph_at_position() {
|
||||
let img = Image::new(80, 60);
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,6 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod gen_golden;
|
||||
mod particle_golden;
|
||||
mod sim_golden;
|
||||
mod timewarp_golden;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -34,6 +34,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
@@ -33,14 +33,16 @@ pub struct Stamp {
|
||||
pub dated: bool,
|
||||
}
|
||||
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
/// Days since 1970-01-01 of the proleptic-Gregorian date `(y, m, d)`. `d` is added linearly (as
|
||||
/// the previous hand-rolled `days_from_civil` did), so a day past the month's end rolls over;
|
||||
/// `None` when the year/month is out of chrono's range. `m` must be 1..=12.
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
|
||||
let year = i32::try_from(y).ok()?;
|
||||
let month = u32::try_from(m).ok()?;
|
||||
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1)?;
|
||||
// Days since the Unix epoch: `num_days_from_ce` counts from 0001-01-01; 719_162 days precede 1970-01-01.
|
||||
use chrono::Datelike;
|
||||
i64::from(first.num_days_from_ce()).checked_sub(719_163)?.checked_add(d.checked_sub(1)?)
|
||||
}
|
||||
|
||||
/// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds.
|
||||
@@ -71,7 +73,7 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option<Stamp> {
|
||||
// ISO 8601: date [T| ] time [zone].
|
||||
if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' {
|
||||
let (y, m, d) = (s[0..4].parse::<i64>().ok()?, s[5..7].parse::<i64>().ok()?, s[8..10].parse::<i64>().ok()?);
|
||||
let mut secs = days_from_civil(y, m, d) as f64 * 86_400.0;
|
||||
let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0;
|
||||
let rest = s[10..].trim_start_matches(['T', 't', ' ']);
|
||||
if !rest.is_empty() {
|
||||
// Zone: Z or ±hh:mm / ±hhmm.
|
||||
|
||||
@@ -13,14 +13,23 @@ use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_pr
|
||||
use crate::{EngineError, Event, Result, Session, cmd, query};
|
||||
|
||||
/// Run `f` (which may run several undoable commands) as one undo step named `label`; on error
|
||||
/// the project is restored.
|
||||
/// the project, editor state and undo history are restored.
|
||||
///
|
||||
/// Front-end events emitted by a failed `f` are discarded with the state. Pending-analysis
|
||||
/// queues and the journal are not part of this rollback and keep their existing behaviour.
|
||||
pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||||
// The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot
|
||||
// taken before them), then become one step on the real one.
|
||||
// taken before them), then become one step on the real one. Keep the complete pre-`f`
|
||||
// history and editor state for a failure: rollback must not leave a partially retargeted
|
||||
// editor or a `f`-recorded redo/branch behind.
|
||||
let saved_history = s.history.clone();
|
||||
let saved = std::mem::take(&mut s.history.undo);
|
||||
let before = s.project.clone();
|
||||
let before_state = s.state.clone();
|
||||
let event_mark = s.events.len();
|
||||
let r = f(s);
|
||||
let changed = !s.history.undo.is_empty();
|
||||
let interim_revision = s.ime_revision;
|
||||
s.history.undo = saved;
|
||||
s.history.merge_key = None;
|
||||
match r {
|
||||
@@ -36,9 +45,22 @@ pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sessi
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
// Snapshot the failing editor before restoring the pre-`f` state: an intermediate
|
||||
// text.edit / text change / comp switch must invalidate the pre-`f` editor token
|
||||
// instead of reviving it.
|
||||
let edited = super::text_edit::active_editor_snapshot(s);
|
||||
s.project = before;
|
||||
s.state = before_state;
|
||||
s.history = saved_history;
|
||||
s.history.merge_key = None;
|
||||
// A failed intermediate comp switch must not reach the front end through an
|
||||
// `OpenComp` event while its state has been rolled back.
|
||||
s.events.truncate(event_mark);
|
||||
s.sanitize_after_replacement(edited.as_ref());
|
||||
// As in the batch rollback, the IME revision is a high-water mark: leave the highest
|
||||
// intermediate stamp rather than rewinding to before `f`.
|
||||
s.ime_revision = s.ime_revision.max(interim_revision);
|
||||
if changed {
|
||||
s.project = before;
|
||||
s.sanitize_state();
|
||||
s.bump();
|
||||
}
|
||||
Err(e)
|
||||
@@ -704,6 +726,36 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn grouped_rollback_restores_the_editor_state_and_history_after_a_transient_text_change() {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 1})).unwrap();
|
||||
let lid = s.execute("layer.newText", json!({"text": "abcdef"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("text.edit", json!({"layer": lid, "caret": 3})).unwrap();
|
||||
let original_comp = s.active_comp_id().unwrap();
|
||||
let owner = s.state.text_edit.as_ref().unwrap().session;
|
||||
let other =
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 160, "height": 90, "duration": 1, "open": false})).unwrap()["comp"].as_u64().unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
s.drain_events();
|
||||
|
||||
let r = super::grouped(&mut s, "Rollback", |s| -> crate::Result<()> {
|
||||
// An intermediate document edit and state switch, both undone by the error path.
|
||||
s.execute("layer.setText", json!({"layer": lid, "text": "ab"}))?;
|
||||
s.execute("comp.open", json!({"comp": other}))?;
|
||||
Err(crate::EngineError::Other("stop".into()))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.active_comp_id(), Some(original_comp), "the pre-group comp is restored");
|
||||
assert_eq!(super::super::text_edit::layer_doc(&s, effectcraft_project::LayerId(lid)).unwrap().text, "abcdef", "the document is restored");
|
||||
assert_eq!(s.history.undo.len(), undo, "the partial grouped steps are not left in history");
|
||||
assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a transient replacement does not revive the old IME token");
|
||||
assert!(
|
||||
!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(_))),
|
||||
"the failed intermediate comp switch is not published to the front end"
|
||||
);
|
||||
}
|
||||
|
||||
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
|
||||
#[test]
|
||||
fn execute_file_opens_an_encoded_file_url() {
|
||||
|
||||
@@ -56,6 +56,9 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let label = str_p(p, "label").unwrap_or("Batch").to_string();
|
||||
let atomic = p.get("atomic").and_then(Value::as_bool).unwrap_or(true);
|
||||
let (project, history, state) = (s.project.clone(), s.history.clone(), s.state.clone());
|
||||
// Rolled-back steps may have queued events (an `OpenComp`, a Toast) for the state that is
|
||||
// about to be dropped: the UI must not act on them.
|
||||
let event_mark = s.events.len();
|
||||
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let run = |s: &mut Session, results: &[Value]| -> Result<Value> {
|
||||
@@ -75,10 +78,21 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Err(e) => {
|
||||
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("?");
|
||||
let note = if atomic {
|
||||
// The snapshot is the intermediate state the failing step left: comparing it
|
||||
// with the restored editor detects a restart or cross-comp switch inside the
|
||||
// batch, so the pre-batch token is never revived. Do not restore
|
||||
// `ime_revision`: a transient text edit inside the batch already advanced
|
||||
// this process-monotonic high-water mark, and rewinding it would reuse a
|
||||
// stamp (letting a wide-selection composition look current). An unrelated
|
||||
// failed batch did not advance it, so its value stays exactly as it was.
|
||||
let edited = crate::commands::text_edit::active_editor_snapshot(s);
|
||||
let interim_revision = s.ime_revision;
|
||||
s.project = project;
|
||||
s.history = history;
|
||||
s.state = state;
|
||||
s.sanitize_state();
|
||||
s.events.truncate(event_mark);
|
||||
s.sanitize_after_replacement(edited.as_ref());
|
||||
s.ime_revision = s.ime_revision.max(interim_revision);
|
||||
s.bump();
|
||||
"; the batch was rolled back (nothing changed)".to_string()
|
||||
} else {
|
||||
|
||||
@@ -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};
|
||||
@@ -73,6 +74,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(s.state.selected_layers.len()))
|
||||
}
|
||||
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// A deselected layer never keeps an open text edit: commit the edit first, exactly as
|
||||
// `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut,
|
||||
// script, MCP — leaves one final state.
|
||||
super::text_edit::end_edit(s, &Value::Null)?;
|
||||
s.state.selected_layers.clear();
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_keys.clear();
|
||||
@@ -201,12 +206,30 @@ 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();
|
||||
st.selected_keys.clear();
|
||||
Ok(())
|
||||
})?;
|
||||
// A removed layer may have been the edited one: end that session before another command
|
||||
// can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session.
|
||||
s.sanitize_state();
|
||||
Ok(json!(ids.len()))
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,101 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"prop": uid, "color": color}))
|
||||
}
|
||||
|
||||
/// What a Cryptomatte effect has at a layer-space point: the ID with the most coverage in its
|
||||
/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection.
|
||||
struct CryptoHit {
|
||||
at: (effectcraft_project::ItemId, LayerId, Tick),
|
||||
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
|
||||
layer: String,
|
||||
hash: u32,
|
||||
selection: String,
|
||||
prop: Uid,
|
||||
/// The Selection has an expression: setting its value wouldn't change what it reads.
|
||||
driven: bool,
|
||||
}
|
||||
|
||||
impl CryptoHit {
|
||||
fn manifest(&self) -> Option<&[(String, u32)]> {
|
||||
self.aux.manifests.iter().find(|(l, _)| *l == self.layer).map(|(_, m)| m.as_slice())
|
||||
}
|
||||
fn reply(&self, sel: &str) -> Value {
|
||||
let selected = effectcraft_effects::selection_hashes(sel, self.manifest()).contains(&self.hash);
|
||||
let name = self.aux.manifest_name(&self.layer, self.hash);
|
||||
json!({"name": name, "hash": format!("{:08x}", self.hash), "layer": self.layer, "selected": selected})
|
||||
}
|
||||
}
|
||||
|
||||
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
|
||||
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
|
||||
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
|
||||
/// (the viewer's resolution).
|
||||
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
|
||||
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
|
||||
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0);
|
||||
if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) {
|
||||
return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?;
|
||||
let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
|
||||
let t = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
|
||||
let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let aux = r.layer_aux(&ctx, layer);
|
||||
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
|
||||
let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else {
|
||||
return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"));
|
||||
};
|
||||
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up,
|
||||
// proxies have their own size, earlier effects can pad it), rendered and cached.
|
||||
let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
|
||||
let (bs, o) = (b.scale, b.offset);
|
||||
// The centre of its pixel under the point, at the aux point the effect reads there.
|
||||
let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]);
|
||||
let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) };
|
||||
let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled));
|
||||
Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
|
||||
}
|
||||
|
||||
/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per
|
||||
/// frame renders the effect's input to learn its pixel grid (cached afterwards).
|
||||
fn crypto_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection)))
|
||||
}
|
||||
|
||||
/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it
|
||||
/// or remove it, in one undo step at the time it was read (none when its meaning stays).
|
||||
fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use effectcraft_effects::crypto_select::{PickMode, edit_selection, token_for};
|
||||
let cmd = "effect.pickCryptomatte";
|
||||
let mode = match str_p(p, "mode").unwrap_or("replace") {
|
||||
"replace" => PickMode::Replace,
|
||||
"add" => PickMode::Add,
|
||||
"remove" => PickMode::Remove,
|
||||
m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))),
|
||||
};
|
||||
let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?;
|
||||
if h.driven {
|
||||
return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick"));
|
||||
}
|
||||
let m = h.manifest();
|
||||
let sel = edit_selection(&h.selection, &token_for(h.aux.manifest_name(&h.layer, h.hash), h.hash, m), h.hash, mode, m);
|
||||
let changed = sel != h.selection;
|
||||
if changed {
|
||||
let (cid, lid, t) = h.at;
|
||||
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?;
|
||||
}
|
||||
let mut out = h.reply(&sel);
|
||||
out["selection"] = json!(sel);
|
||||
out["changed"] = json!(changed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let fx = effects_p(s, p, "effect.remove")?;
|
||||
@@ -632,6 +727,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
pick_color
|
||||
),
|
||||
cmd!(
|
||||
"effect.pickCryptomatte",
|
||||
"Pick Cryptomatte",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point",
|
||||
has_layers,
|
||||
pick_crypto
|
||||
),
|
||||
crate::query!(
|
||||
"effect.cryptomatteAt",
|
||||
"Cryptomatte At",
|
||||
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point",
|
||||
crypto_at
|
||||
),
|
||||
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
|
||||
cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all),
|
||||
cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove),
|
||||
|
||||
@@ -665,6 +665,15 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t)
|
||||
.ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?;
|
||||
}
|
||||
if let Some(f) = p.get("frameStart") {
|
||||
// Frame Count: After Effects offers Start at 0 and Start at 1 only. Scripts and dialog
|
||||
// number fields may send `1.0`.
|
||||
proj.settings.frame_start = match f.as_f64() {
|
||||
Some(0.0) => 0,
|
||||
Some(1.0) => 1,
|
||||
_ => return Err(bad(cmd, format!("frameStart: 0|1, not `{f}`"))),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(serde_json::to_value(&s.project.settings).unwrap_or_default())
|
||||
@@ -745,7 +754,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Project Settings...",
|
||||
["File"],
|
||||
Some("Cmd+Alt+Shift+K"),
|
||||
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}",
|
||||
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16, frameStart?: 0|1 (Frame Count: Start at 0 / Start at 1)}",
|
||||
always,
|
||||
project_settings
|
||||
),
|
||||
|
||||
@@ -4,8 +4,8 @@ use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
limits,
|
||||
Comp, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid,
|
||||
TrackMatte, limits,
|
||||
};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
@@ -200,7 +200,14 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = doc.char_len();
|
||||
let id = insert_layer(proj, st, cid, l)?;
|
||||
if edit {
|
||||
st.text_edit = Some(super::text_edit::TextEdit { layer: id, anchor: 0, caret: n, created: true, ..Default::default() });
|
||||
st.text_edit = Some(super::text_edit::TextEdit {
|
||||
layer: id,
|
||||
anchor: 0,
|
||||
caret: n,
|
||||
created: true,
|
||||
session: super::text_edit::next_edit_session(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -307,6 +314,103 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
||||
/// A layer whose source is an item (footage, precomposition or solid), as in After Effects'
|
||||
/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to
|
||||
/// replace.
|
||||
fn replaceable_layer(l: &Layer) -> bool {
|
||||
matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. })
|
||||
}
|
||||
|
||||
/// The source a Project item gives a layer, or why it can't replace one.
|
||||
fn replacement_source(item: ItemId, it: &effectcraft_project::Item) -> Result<LayerSource> {
|
||||
const CMD: &str = "layer.replaceSource";
|
||||
match &it.kind {
|
||||
ItemKind::Comp(_) => Ok(LayerSource::Comp { item }),
|
||||
ItemKind::Footage(f) if f.kind == FootageKind::Data => {
|
||||
Err(bad(CMD, "data files can't be layer sources; read them in expressions with footage(\"name\").sourceData"))
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == FootageKind::Model => Err(bad(CMD, "3D model items can't replace a 2D layer source; add them as 3D layers")),
|
||||
ItemKind::Footage(_) => Ok(LayerSource::Footage { item }),
|
||||
ItemKind::Solid(_) => Ok(LayerSource::Solid { item }),
|
||||
ItemKind::Folder => Err(bad(CMD, "folders can't be layer sources")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a source brings audio properties (precompositions always can; footage when it has an
|
||||
/// audio stream).
|
||||
fn source_has_audio(project: &Project, source: &LayerSource) -> bool {
|
||||
match source {
|
||||
LayerSource::Comp { .. } => true,
|
||||
LayerSource::Footage { item } => project.item(*item).is_some_and(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_audio)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_replace_source(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
if !s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(replaceable_layer) {
|
||||
return Err("select a footage, composition or solid layer".into());
|
||||
}
|
||||
if s.state.project_selection.is_empty() {
|
||||
return Err("select a footage item, a composition or a solid in the Project panel".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the source of the selected (or `layers`) AV layers with a Project item, as After
|
||||
/// Effects' Alt-drag and Ctrl+Alt+/ do. The layer keeps every property: name, timing, transforms,
|
||||
/// keyframes, expressions, effects, masks, switches, parenting and track matte. Only its source
|
||||
/// changes; precompositions bring their Essential Properties group through `sync_project`, and a
|
||||
/// source with audio adds an Audio group when the layer has none.
|
||||
fn replace_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "layer.replaceSource";
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = unlocked(s, cid, ids, CMD)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(CMD, "select a layer first"));
|
||||
}
|
||||
let item = match p.get("item") {
|
||||
Some(Value::Number(n)) => ItemId(n.as_u64().ok_or_else(|| bad(CMD, "item: a project item id"))?),
|
||||
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(CMD, format!("no item `{name}`")))?,
|
||||
_ => *s.state.project_selection.first().ok_or_else(|| bad(CMD, "select a footage item, a composition or a solid in the Project panel"))?,
|
||||
};
|
||||
let it = s.project.item(item).ok_or_else(|| bad(CMD, format!("no item {}", item.0)))?;
|
||||
let source = replacement_source(item, it)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
for lid in &ids {
|
||||
let l = comp.layer(*lid).ok_or_else(|| bad(CMD, "no such layer"))?;
|
||||
if !replaceable_layer(l) {
|
||||
return Err(bad(CMD, format!("“{}” has no source item to replace", l.name)));
|
||||
}
|
||||
}
|
||||
if let LayerSource::Comp { item: new } = &source
|
||||
&& s.project.comp_contains(*new, cid)
|
||||
{
|
||||
return Err(bad(CMD, "a composition can't contain itself"));
|
||||
}
|
||||
let has_audio = source_has_audio(&s.project, &source);
|
||||
let source_for_edit = source.clone();
|
||||
s.edit("Replace Layer Source", None, |proj, _| {
|
||||
for lid in &ids {
|
||||
let needs_audio = has_audio && proj.comp(cid).and_then(|c| c.layer(*lid)).is_some_and(|l| l.props.sub("audio").is_none());
|
||||
let audio = if needs_audio {
|
||||
let mut next = Ids(&mut proj.next_id);
|
||||
Some(build::audio(&mut next))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
l.source = source_for_edit.clone();
|
||||
if let Some(audio) = audio {
|
||||
l.props.children.push(audio.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"layers": ids.iter().map(|l| l.0).collect::<Vec<_>>(), "item": item.0}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
@@ -417,8 +521,10 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
|
||||
let applies = |l: &Layer| match name {
|
||||
"audio" => l.props.sub("audio").is_some(),
|
||||
"lock" | "locked" => true,
|
||||
// Nulls have a Video switch too (it hides their outline in the viewer).
|
||||
"video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id),
|
||||
// Every layer has a Video switch (it hides a null's outline in the viewer and turns a
|
||||
// camera or light off), as the Timeline's eye cell shows; track mattes keep theirs off
|
||||
// by design.
|
||||
"video" | "eye" => !mattes.contains(&l.id),
|
||||
_ => l.source.is_av() && !mattes.contains(&l.id),
|
||||
};
|
||||
let mut alone = true;
|
||||
@@ -1375,6 +1481,9 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.bump();
|
||||
}
|
||||
// A changed Source Text is handled by `Session::edit`: its boundary clamps the selection and
|
||||
// starts a new IME epoch only when the CTI document actually changed, so an identical text
|
||||
// write stays a no-op and keeps the editor's owner.
|
||||
s.edit("Edit Text", merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
@@ -1628,6 +1737,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_comp,
|
||||
add_item
|
||||
),
|
||||
cmd!(
|
||||
"layer.replaceSource",
|
||||
"Replace Layer Source",
|
||||
[],
|
||||
Some("Cmd+Alt+/"),
|
||||
"{comp?, layers?, layer?, item?: id|name (default the Project panel selection), fixExpressions?: bool (accepted for After Effects compatibility)} → {layers, item}",
|
||||
has_replace_source,
|
||||
replace_source
|
||||
),
|
||||
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
|
||||
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
|
||||
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
|
||||
|
||||
@@ -114,10 +114,10 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
|
||||
if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
// Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's
|
||||
// eye cell shows): the selection stays shown.
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn reveal_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = source_item(s, p, "layer.revealSource")?;
|
||||
s.state.project_selection = vec![item];
|
||||
frontend(s, "window.panel", &json!({"panel": "project"}))?;
|
||||
Ok(json!({"item": item.0}))
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let mut items: Vec<ItemId> = Vec::new();
|
||||
{
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
for layer in ids.iter().filter_map(|id| comp.layer(*id)) {
|
||||
if let Some(item) = layer.source.item()
|
||||
&& s.project.item(item).is_some()
|
||||
&& !items.contains(&item)
|
||||
{
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(bad("layer.revealSource", "the selected layers have no source item"));
|
||||
}
|
||||
s.state.project_selection = items.clone();
|
||||
let raw: Vec<u64> = items.iter().map(|item| item.0).collect();
|
||||
frontend(s, "project.reveal", &json!({"items": raw}))?;
|
||||
Ok(json!({"item": items[0].0, "items": raw}))
|
||||
}
|
||||
|
||||
fn reveal_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.state.project_selection = vec![cid];
|
||||
frontend(s, "window.panel", &json!({"panel": "project"}))?;
|
||||
Ok(json!({"item": cid.0}))
|
||||
frontend(s, "project.reveal", &json!({"items": [cid.0]}))?;
|
||||
Ok(json!({"item": cid.0, "items": [cid.0]}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -909,7 +925,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op),
|
||||
cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source),
|
||||
cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder),
|
||||
cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source),
|
||||
cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layers?}", has_source_item, reveal_source),
|
||||
cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp),
|
||||
cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors),
|
||||
cmd!(
|
||||
|
||||
@@ -22,8 +22,11 @@ pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Lay
|
||||
});
|
||||
}
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?;
|
||||
let pr = layer.props.prop(path).ok_or_else(|| {
|
||||
let group = layer.props.group(path).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid)));
|
||||
// `effects/<match name>[#n]/<property>` is how `essential.list`, presets and Copy write an
|
||||
// effect property; turn it into the uid path `PropGroup::prop` parses (#2064).
|
||||
let resolved = super::resolve_effect_path(layer, path);
|
||||
let pr = layer.props.prop(&resolved).ok_or_else(|| {
|
||||
let group = layer.props.group(&resolved).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid)));
|
||||
if group.is_some() {
|
||||
bad(cmd, format!("`{path}` is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property"))
|
||||
} else {
|
||||
@@ -179,7 +182,9 @@ pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: &
|
||||
pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value {
|
||||
let ct = layer.comp_time(t);
|
||||
let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()});
|
||||
if !layer.is_active_at(ct) {
|
||||
// A key exactly on the out point ends a fade normally: it is outside the half-open visible
|
||||
// range, but it is not a caller mistake, so it does not warn (#2084).
|
||||
if ct < layer.in_point || ct > layer.out_point {
|
||||
out["warning"] = json!(format!(
|
||||
"Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).",
|
||||
ct.seconds(),
|
||||
|
||||
@@ -6,15 +6,58 @@
|
||||
//! Positions are character indices. The selection is `anchor..caret` (either order).
|
||||
|
||||
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
|
||||
use effectcraft_project::{LayerId, LayerSource};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Session tokens for the on-canvas editor, unique across the process (so a recreated session
|
||||
/// never reuses a live IME context). Exhaustion wraps; it cannot panic.
|
||||
static NEXT_EDIT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
pub(super) fn next_edit_session() -> u64 {
|
||||
NEXT_EDIT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Process-global checked high-water allocator for [`crate::Session::ime_revision`] stamps.
|
||||
///
|
||||
/// egui's unavailable-IME receipt identity must be process-monotonic. Allocating the stamp from
|
||||
/// one process-global counter means neither a second session nor a rollback can reuse a value.
|
||||
/// The counter fails closed at `u64::MAX`: it returns `None` without wrapping, and callers keep
|
||||
/// their existing stamp. An unavailable before/after pair then compares equal, so it earns no
|
||||
/// receipt rather than claiming a mutation with a reused revision.
|
||||
static NEXT_IME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
pub(crate) fn next_ime_revision() -> Option<u64> {
|
||||
allocate_ime_revision(&NEXT_IME_REVISION)
|
||||
}
|
||||
|
||||
fn allocate_ime_revision(counter: &std::sync::atomic::AtomicU64) -> Option<u64> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut current = counter.load(Relaxed);
|
||||
loop {
|
||||
let next = current.checked_add(1)?;
|
||||
match counter.compare_exchange_weak(current, next, Relaxed, Relaxed) {
|
||||
Ok(_) => return Some(next),
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_revision_allocation_stops_at_exhaustion_without_reusing_a_stamp() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
let counter = AtomicU64::new(u64::MAX - 1);
|
||||
assert_eq!(allocate_ime_revision(&counter), Some(u64::MAX));
|
||||
assert_eq!(allocate_ime_revision(&counter), None);
|
||||
assert_eq!(allocate_ime_revision(&counter), None);
|
||||
assert_eq!(counter.load(Relaxed), u64::MAX);
|
||||
}
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, layer_mut, resolve_layer, str_p};
|
||||
use crate::{EditorState, EngineError, Result, Session, cmd};
|
||||
|
||||
/// An on-canvas text editing session.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextEdit {
|
||||
pub layer: LayerId,
|
||||
pub anchor: usize,
|
||||
@@ -28,6 +71,23 @@ pub struct TextEdit {
|
||||
/// Line position kept by repeated up / down moves (layout space).
|
||||
#[serde(default)]
|
||||
pub goal_x: Option<f64>,
|
||||
/// This editing session's identity.
|
||||
///
|
||||
/// It changes when the editor is started and when its layer or document is replaced, by
|
||||
/// undo/redo, a history jump, a rollback, or a time change showing another document. A
|
||||
/// restore that keeps the same composition, layer and byte/style-equal document (an opacity
|
||||
/// undo while editing) keeps it, so an active IME composition survives an unrelated change.
|
||||
/// It is deliberately not serialized: a saved token belongs to a previous process and
|
||||
/// cannot identify a new target. The IME owner uses it as its replacement epoch, so an old
|
||||
/// composition is never inherited by a recreated layer or a replaced document.
|
||||
#[serde(skip, default = "next_edit_session")]
|
||||
pub session: u64,
|
||||
}
|
||||
|
||||
impl Default for TextEdit {
|
||||
fn default() -> Self {
|
||||
Self { layer: LayerId(0), anchor: 0, caret: 0, pending: None, created: false, goal_x: None, session: next_edit_session() }
|
||||
}
|
||||
}
|
||||
|
||||
impl TextEdit {
|
||||
@@ -49,6 +109,139 @@ pub fn layer_doc(s: &Session, lid: LayerId) -> Option<TextDoc> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest committed-text window an IME observation copies.
|
||||
///
|
||||
/// This mirrors `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`: a selection wider than this is
|
||||
/// reported as unavailable rather than silently truncated.
|
||||
pub const MAX_IME_TEXT_BYTES: usize = 4000;
|
||||
|
||||
/// The editor's committed-text revision for an IME receipt whose surrounding text is
|
||||
/// unavailable (a selection wider than [`MAX_IME_TEXT_BYTES`]).
|
||||
///
|
||||
/// Unlike [`Session::revision`], unrelated project edits (an opacity undo) do not advance it, so
|
||||
/// they cannot make an active composition look stale.
|
||||
pub fn ime_stamp_revision(s: &Session) -> u64 {
|
||||
s.ime_revision
|
||||
}
|
||||
|
||||
/// Bounded committed text around an IME selection, with UTF-8 byte offsets.
|
||||
///
|
||||
/// The Type tool keeps the input method's pre-edit in egui's temporary data, never in the
|
||||
/// layer's [`TextDoc`], so [`Self::text`] is committed text only.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeSurrounding {
|
||||
/// At most [`MAX_IME_TEXT_BYTES`] bytes of the edited layer's text.
|
||||
pub text: String,
|
||||
/// The caret (primary selection end) as a UTF-8 byte offset in [`Self::text`].
|
||||
pub cursor: usize,
|
||||
/// The other selection end; larger than `cursor` for a reversed selection.
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
impl ImeSurrounding {
|
||||
/// A bounded window of `text` around the selection `cursor`/`anchor`.
|
||||
///
|
||||
/// Returns `None` when an offset is past the text or inside a character, or when the
|
||||
/// selection itself is wider than [`MAX_IME_TEXT_BYTES`]: the caller then reports
|
||||
/// surrounding text as unavailable instead of sending a truncated selection.
|
||||
pub fn from_text(text: &str, cursor: usize, anchor: usize) -> Option<Self> {
|
||||
if cursor > text.len() || anchor > text.len() || !text.is_char_boundary(cursor) || !text.is_char_boundary(anchor) {
|
||||
return None;
|
||||
}
|
||||
let (start, end) = (cursor.min(anchor), cursor.max(anchor));
|
||||
let selection = end - start;
|
||||
if selection > MAX_IME_TEXT_BYTES {
|
||||
return None;
|
||||
}
|
||||
// Spend the remaining budget on both sides of the selection, giving unused room on one
|
||||
// side to the other. Only this bounded window is copied.
|
||||
let budget = MAX_IME_TEXT_BYTES - selection;
|
||||
let mut before = (budget / 2).min(start);
|
||||
let mut after = (budget - before).min(text.len() - end);
|
||||
before = (budget - after).min(start);
|
||||
after = (budget - before).min(text.len() - end);
|
||||
let mut window_start = start - before;
|
||||
let mut window_end = end + after;
|
||||
// Byte budgets can land inside a multi-byte character: shrink the window so it never
|
||||
// splits one and the selection stays inside it.
|
||||
while window_start < start && !text.is_char_boundary(window_start) {
|
||||
window_start += 1;
|
||||
}
|
||||
while end < window_end && !text.is_char_boundary(window_end) {
|
||||
window_end -= 1;
|
||||
}
|
||||
Some(Self {
|
||||
text: text.get(window_start..window_end)?.to_owned(),
|
||||
cursor: cursor.checked_sub(window_start)?,
|
||||
anchor: anchor.checked_sub(window_start)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The edited layer's committed text around its selection, or `None` when there is no edit
|
||||
/// session, the layer is gone, or the selection is wider than [`MAX_IME_TEXT_BYTES`].
|
||||
///
|
||||
/// The caret and the anchor are converted to UTF-8 byte offsets separately, so a reversed
|
||||
/// selection stays reversed. A valid empty document is `Some` with empty text, never `None`.
|
||||
pub fn ime_surrounding(s: &Session) -> Option<ImeSurrounding> {
|
||||
let e = s.state.text_edit.as_ref()?;
|
||||
let comp = s.active_comp()?;
|
||||
let layer = comp.layer(e.layer)?;
|
||||
let prop = layer.props.prop("text/sourceText")?;
|
||||
// A static Source Text (the normal case) is borrowed in place; only an animated one has to
|
||||
// be evaluated into a temporary. Either way only the bounded window is copied.
|
||||
if !prop.is_animated() {
|
||||
return match &prop.value {
|
||||
KV::Text(doc) => {
|
||||
let (caret, anchor) = doc_bytes(doc, e.caret, e.anchor);
|
||||
ImeSurrounding::from_text(&doc.text, caret, anchor)
|
||||
}
|
||||
KV::Str(text) => {
|
||||
let (caret, anchor) = char_bytes(text, e.caret, e.anchor);
|
||||
ImeSurrounding::from_text(text, caret, anchor)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
match prop.value_at(layer.layer_time(s.time())) {
|
||||
KV::Text(doc) => {
|
||||
let (caret, anchor) = doc_bytes(&doc, e.caret, e.anchor);
|
||||
ImeSurrounding::from_text(&doc.text, caret, anchor)
|
||||
}
|
||||
KV::Str(text) => {
|
||||
let (caret, anchor) = char_bytes(&text, e.caret, e.anchor);
|
||||
ImeSurrounding::from_text(&text, caret, anchor)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offsets of two character positions in `text` (clamped to its end), scanning once.
|
||||
///
|
||||
/// The second position may be before the first; the returned pair keeps the caller's order.
|
||||
fn char_bytes(text: &str, first: usize, second: usize) -> (usize, usize) {
|
||||
let (lo, hi) = if first <= second { (first, second) } else { (second, first) };
|
||||
let mut lo_byte = if lo == 0 { 0 } else { text.len() };
|
||||
let mut hi_byte = if hi == 0 { 0 } else { text.len() };
|
||||
if hi != 0 {
|
||||
for (i, (b, _)) in text.char_indices().enumerate() {
|
||||
if i == lo {
|
||||
lo_byte = b;
|
||||
}
|
||||
if i == hi {
|
||||
hi_byte = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if first <= second { (lo_byte, hi_byte) } else { (hi_byte, lo_byte) }
|
||||
}
|
||||
|
||||
/// Byte offsets of the editor's caret and anchor in a document, in that order.
|
||||
fn doc_bytes(doc: &TextDoc, caret: usize, anchor: usize) -> (usize, usize) {
|
||||
char_bytes(&doc.text, caret, anchor)
|
||||
}
|
||||
|
||||
/// The text layer a text command targets: `layer`, the edited layer, or the selected text layer.
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<LayerId> {
|
||||
let cid = comp_id(s, p)?;
|
||||
@@ -78,7 +271,7 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
|
||||
Some(e) if e.layer == lid => e.clone(),
|
||||
_ => {
|
||||
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
||||
let e = TextEdit { layer: lid, anchor: n, caret: n, ..Default::default() };
|
||||
let e = TextEdit { layer: lid, anchor: n, caret: n, session: next_edit_session(), ..Default::default() };
|
||||
s.state.selected_layers = vec![lid];
|
||||
s.state.text_edit = Some(e.clone());
|
||||
e
|
||||
@@ -101,7 +294,8 @@ pub(crate) fn edit_doc(
|
||||
) -> Result<()> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let t = s.time();
|
||||
s.edit(label, merge, |proj, st| {
|
||||
let revision = s.revision;
|
||||
s.edit_text_doc(label, merge, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?;
|
||||
@@ -110,16 +304,28 @@ pub(crate) fn edit_doc(
|
||||
KV::Str(t) => TextDoc::plain(&t),
|
||||
_ => TextDoc::default(),
|
||||
};
|
||||
let before = first_line_name(&doc.text);
|
||||
let original = doc.clone();
|
||||
f(&mut doc, st)?;
|
||||
let after = first_line_name(&doc.text);
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
||||
// Text layers are named after their text until renamed.
|
||||
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
||||
l.name = after;
|
||||
// A no-op (an IME deletion with nothing around the selection, a set to the value it
|
||||
// already has) must not turn a static Str into Text or add a keyframe at the CTI: that
|
||||
// would change the project and record an empty undo step.
|
||||
if doc != original {
|
||||
let before = first_line_name(&original.text);
|
||||
let after = first_line_name(&doc.text);
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
||||
// Text layers are named after their text until renamed.
|
||||
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
||||
l.name = after;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
// The editor's unavailable-text receipt identity follows only its own document: an edit to
|
||||
// another text layer while this editor is open must not cancel the composition.
|
||||
if s.revision != revision && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid) {
|
||||
s.note_text_edit_change();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
||||
@@ -153,13 +359,13 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let created = b_p(p, "created").unwrap_or(false);
|
||||
s.state.selected_layers = vec![lid];
|
||||
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, ..Default::default() });
|
||||
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, session: next_edit_session(), ..Default::default() });
|
||||
s.bump();
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
|
||||
fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
|
||||
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
@@ -314,6 +520,78 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// How many whole characters `before_bytes` / `after_bytes` cover around `r`.
|
||||
///
|
||||
/// This is the input method's own byte count, as winit reports it: a count that ends inside a
|
||||
/// UTF-8 character leaves that whole character (stock egui's `surrounding_bytes_to_chars`), so a
|
||||
/// malformed or platform-specific offset never splits or corrupts the document.
|
||||
fn surrounding_bytes_as_chars(text: &str, n: usize, r: std::ops::Range<usize>, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
|
||||
let r = r.start.min(n)..r.end.min(n);
|
||||
let (start_byte, end_byte) = char_bytes(text, r.start, r.end);
|
||||
let from = start_byte.saturating_sub(before_bytes);
|
||||
let before = text.get(..start_byte).map_or(0, |head| head.char_indices().rev().take_while(|(i, _)| *i >= from).count());
|
||||
let after = text.get(end_byte..).map_or(0, |tail| tail.char_indices().take_while(|(i, c)| i.saturating_add(c.len_utf8()) <= after_bytes).count());
|
||||
(before.min(r.start), after.min(n.saturating_sub(r.end)))
|
||||
}
|
||||
|
||||
/// Delete the text an input method marks for replacement around the selection
|
||||
/// (`DeleteSurrounding`, `DeleteSurroundingBytes`).
|
||||
///
|
||||
/// Unlike [`delete`] with an explicit range, the **selection stays**: the characters before its
|
||||
/// start and after its end are removed in two separate ranges, and the following `Commit`
|
||||
/// replaces the selected text. `bytes` counts UTF-8 bytes (`before`/`after` are characters
|
||||
/// otherwise); a count that ends inside a character leaves that character.
|
||||
///
|
||||
/// The counts are computed from the one document `edit_doc` already evaluated, so an animated
|
||||
/// Source Text and a large static document are not copied or evaluated again just to decide what
|
||||
/// the input method asked to replace.
|
||||
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.deleteSurrounding")?;
|
||||
// Only an open edit session has a selection to keep: without one there is nothing an input
|
||||
// method could be replacing.
|
||||
let e = match &s.state.text_edit {
|
||||
Some(e) if e.layer == lid => e.clone(),
|
||||
_ => return Err(bad("text.deleteSurrounding", "not editing this text layer")),
|
||||
};
|
||||
let count = |k: &str| p.get(k).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
|
||||
let (before_count, after_count) = (count("before"), count("after"));
|
||||
let bytes = b_p(p, "bytes").unwrap_or(false);
|
||||
let merge = str_p(p, "merge").map(str::to_string);
|
||||
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
|
||||
let n = doc.char_len();
|
||||
let r = e.range().start.min(n)..e.range().end.min(n);
|
||||
let (before, after) = if bytes {
|
||||
surrounding_bytes_as_chars(&doc.text, n, r.clone(), before_count, after_count)
|
||||
} else {
|
||||
(before_count.min(r.start), after_count.min(n.saturating_sub(r.end)))
|
||||
};
|
||||
let start = r.start.saturating_sub(before);
|
||||
let end = r.end.saturating_add(after).min(n);
|
||||
if start == r.start && end == r.end {
|
||||
// Deletion-only batches with an empty count, or a document with nothing around the
|
||||
// selection: no empty undo step.
|
||||
return Ok(());
|
||||
}
|
||||
// The selection keeps its text: remove after it first, then before it, so the two ranges
|
||||
// stay valid and the caret/selection shift by the characters before it.
|
||||
if end > r.end {
|
||||
doc.replace_range(r.end..end, "", None);
|
||||
}
|
||||
if r.start > start {
|
||||
doc.replace_range(start..r.start, "", None);
|
||||
}
|
||||
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
|
||||
let shift = r.start.saturating_sub(start);
|
||||
ed.anchor = ed.anchor.saturating_sub(shift);
|
||||
ed.caret = ed.caret.saturating_sub(shift);
|
||||
ed.pending = None;
|
||||
ed.goal_x = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Selected text (plain) of the session, and its formatted fragment.
|
||||
fn selection_doc(s: &Session) -> Option<TextDoc> {
|
||||
let e = s.state.text_edit.as_ref()?;
|
||||
@@ -448,16 +726,111 @@ fn paste_formatting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Keep the session valid after undo / external edits: clamp the selection, drop it when the
|
||||
/// Give the current editor a fresh replacement epoch after its document/layer was replaced.
|
||||
pub(crate) fn invalidate_session(s: &mut Session) {
|
||||
let replaced = s.state.text_edit.is_some();
|
||||
if let Some(e) = s.state.text_edit.as_mut() {
|
||||
e.session = next_edit_session();
|
||||
}
|
||||
if replaced {
|
||||
s.note_text_edit_change();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp the editor selection and give it a fresh IME epoch after its document was replaced.
|
||||
///
|
||||
/// This is the one replacement path for document changes outside `edit_doc`:
|
||||
/// [`crate::Session::edit`] calls [`after_external_change`] after every edit, [`crate::Session::set_time`]
|
||||
/// and [`crate::Session::sync_related_times`] call it after moving the CTI, and
|
||||
/// [`crate::Session::sanitize_after_replacement`] calls it with the editor document snapshotted
|
||||
/// before a history restore or rollback.
|
||||
/// Clamping first means a shortened document leaves the caret in range, so the next Backspace
|
||||
/// deletes instead of being swallowed by an out-of-range selection.
|
||||
pub(crate) fn document_replaced(s: &mut Session) {
|
||||
sanitize(s);
|
||||
invalidate_session(s);
|
||||
}
|
||||
|
||||
/// The identity and document of the active on-canvas editor at one moment.
|
||||
///
|
||||
/// The composition and the editor session are part of the identity: a restoration must not keep
|
||||
/// a composition alive when the snapshot came from another comp, or from an intermediate editor
|
||||
/// that was restarted during the operation being rolled back.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EditorSnapshot {
|
||||
/// The composition the editor was attached to.
|
||||
pub comp: ItemId,
|
||||
/// The edited layer.
|
||||
pub layer: LayerId,
|
||||
/// The editor session token, unique across the process.
|
||||
pub session: u64,
|
||||
/// The Source Text the editor was showing.
|
||||
pub doc: TextDoc,
|
||||
}
|
||||
|
||||
/// The identity and document the active on-canvas editor is showing.
|
||||
///
|
||||
/// [`crate::Session::edit`] and the time setters snapshot this before a change and compare it
|
||||
/// afterwards: only a change that actually replaces this document starts a new epoch, while a
|
||||
/// write to another property, layer or composition, a move to a time showing the same text, or a
|
||||
/// write that leaves the text as it was, keeps the editor's owner. With no editor open this is a
|
||||
/// single `Option` check, with no document copy.
|
||||
pub fn active_editor_snapshot(s: &Session) -> Option<EditorSnapshot> {
|
||||
let e = s.state.text_edit.as_ref()?;
|
||||
Some(EditorSnapshot { comp: s.active_comp_id()?, layer: e.layer, session: e.session, doc: layer_doc(s, e.layer)? })
|
||||
}
|
||||
|
||||
/// Finish an edit outside the editor: if it replaced the document the editor owned, clamp the
|
||||
/// selection and start a new IME epoch.
|
||||
///
|
||||
/// An edit that itself moves or closes the editor owns the resulting session, so an identity
|
||||
/// mismatch is not a replacement here.
|
||||
pub(crate) fn after_external_change(s: &mut Session, before: Option<&EditorSnapshot>) {
|
||||
let Some(before) = before else { return };
|
||||
let Some(e) = s.state.text_edit.as_ref() else { return };
|
||||
if s.state.active_comp != Some(before.comp) || e.layer != before.layer || e.session != before.session {
|
||||
return;
|
||||
}
|
||||
if layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
|
||||
document_replaced(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a project-state replacement (undo, redo, a history jump, a rollback): keep the editor
|
||||
/// only when the restored state still has the same composition, layer and editor session and
|
||||
/// shows byte/style-equal text.
|
||||
///
|
||||
/// The restored session token is the one saved in the replaced state, so an intermediate
|
||||
/// restart or cross-comp switch must be detected against the snapshot instead of being assumed
|
||||
/// to be the editor's own change.
|
||||
pub(crate) fn after_replacement(s: &mut Session, before: Option<&EditorSnapshot>) {
|
||||
let Some(before) = before else {
|
||||
// No editor was open when the snapshot was taken: a restored editor must not inherit a
|
||||
// composition from the state the replacement replaced.
|
||||
if s.state.text_edit.is_some() {
|
||||
document_replaced(s);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let same = s.state.text_edit.as_ref().is_some_and(|e| s.state.active_comp == Some(before.comp) && e.layer == before.layer && e.session == before.session);
|
||||
if !same || layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
|
||||
document_replaced(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the session valid after routine state cleanup: clamp the selection, drop it when the
|
||||
/// layer is gone or not in the active comp.
|
||||
///
|
||||
/// This is also called after project-item cleanup that leaves the edited document alone, so it
|
||||
/// must not advance [`TextEdit::session`]: that would make the viewer treat unrelated cleanup as
|
||||
/// a document replacement and interrupt the active IME composition. Replacement paths call
|
||||
/// [`document_replaced`] instead.
|
||||
pub(crate) fn sanitize(s: &mut Session) {
|
||||
let Some(e) = s.state.text_edit.clone() else { return };
|
||||
match layer_doc(s, e.layer) {
|
||||
Some(d) => {
|
||||
let n = d.char_len();
|
||||
if (e.anchor > n || e.caret > n)
|
||||
&& let Some(ed) = s.state.text_edit.as_mut()
|
||||
{
|
||||
if let Some(ed) = s.state.text_edit.as_mut() {
|
||||
ed.anchor = ed.anchor.min(n);
|
||||
ed.caret = ed.caret.min(n);
|
||||
}
|
||||
@@ -594,6 +967,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
editing_or_text_layer,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"text.deleteSurrounding",
|
||||
"Delete Text Around Selection",
|
||||
[],
|
||||
None,
|
||||
"{layer?, before?, after?, bytes?, merge?} (input-method replacement: the characters before and after the selection are deleted, the selection stays; bytes counts UTF-8 bytes)",
|
||||
editing_or_text_layer,
|
||||
delete_surrounding
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteTextMatchFormatting",
|
||||
"Paste Text and Match Formatting",
|
||||
|
||||
@@ -28,6 +28,20 @@ pub fn display_time(s: &Session, comp: &Comp, t: Tick) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A length (a layer's Duration) in the project's Time Display Style: a count of frames, so
|
||||
/// neither Start Numbering Frames At nor the comp's Start Timecode moves it.
|
||||
pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String {
|
||||
let fr = comp.frame_rate;
|
||||
let n = fr.frame_at(len);
|
||||
match s.project.settings.time_display {
|
||||
TimeDisplayStyle::Frames => format!("{n:05}"),
|
||||
TimeDisplayStyle::Feet35 | TimeDisplayStyle::Feet16 => {
|
||||
effectcraft_time::format_feet_frames(n, s.project.settings.time_display.frames_per_foot().unwrap_or(16))
|
||||
}
|
||||
TimeDisplayStyle::Timecode => effectcraft_time::format_timecode_ae(n, fr, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame
|
||||
/// `current`) into a comp frame.
|
||||
pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result<i64, String> {
|
||||
|
||||
@@ -46,13 +46,22 @@ pub fn resolve(stored: &str, project: Option<&str>) -> Result<PathBuf, NotFollow
|
||||
let p = Path::new(p);
|
||||
std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
});
|
||||
let path = craft_docpath::resolve(stored, project.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// project does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NoProjectFolder);
|
||||
}
|
||||
Ok(path)
|
||||
craft_docpath::resolve_absolute(stored, project.as_deref()).map_err(|why| match why {
|
||||
// Resolved, but still relative: it would be taken from the working directory, which the
|
||||
// project does not choose. A Windows drive or share path (`C:\x`) stays invalid outside
|
||||
// Windows even with a project.
|
||||
Refused::Invalid if project.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| cfg!(windows) || !is_windows_rooted(&p)) => {
|
||||
NotFollowed::NoProjectFolder
|
||||
}
|
||||
why => NotFollowed::Refused(why),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `p` is a Windows drive or share path, which a project cannot make absolute outside Windows.
|
||||
fn is_windows_rooted(p: &Path) -> bool {
|
||||
let s = p.to_string_lossy();
|
||||
let b = s.as_bytes();
|
||||
(b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') || s.starts_with(r"\\") || s.starts_with("//")
|
||||
}
|
||||
|
||||
/// The paths of `f` (its file and every frame of an image sequence) resolved against the document
|
||||
|
||||
@@ -34,9 +34,11 @@ impl Services for Recording {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probes every file as a still.
|
||||
/// Probes every file as a still. Only the Unix part of the test below imports files.
|
||||
#[cfg(unix)]
|
||||
struct Stills;
|
||||
|
||||
#[cfg(unix)]
|
||||
impl crate::Importer for Stills {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !std::path::Path::new(path).is_file() {
|
||||
@@ -147,7 +149,23 @@ fn stored_paths_resolve_against_the_project() {
|
||||
let doc = doc.to_str().unwrap();
|
||||
assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov")));
|
||||
assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder));
|
||||
assert_eq!(super::resolve("../logo.png", None), Err(super::NotFollowed::NoProjectFolder));
|
||||
// A drive path is not a local file on Unix, with or without a project: it is not a missing project.
|
||||
#[cfg(unix)]
|
||||
for stored in [r"C:\x.mov", "C:x.mov"] {
|
||||
assert_eq!(super::resolve(stored, None), Err(super::NotFollowed::Refused(super::Refused::Invalid)), "{stored}");
|
||||
}
|
||||
assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network)));
|
||||
assert_eq!(super::resolve("jar:clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Url)));
|
||||
// Windows reads a colon in a name as an alternate data stream; elsewhere it is a plain character.
|
||||
if cfg!(windows) {
|
||||
assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid)));
|
||||
} else {
|
||||
assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("shots").join("take:1.mov")));
|
||||
}
|
||||
// A Windows drive path names no file on other platforms.
|
||||
#[cfg(unix)]
|
||||
assert_eq!(super::resolve(r"C:\art\clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid)));
|
||||
#[cfg(windows)]
|
||||
assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -341,13 +341,14 @@ impl Session {
|
||||
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
|
||||
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
|
||||
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
|
||||
let edited = crate::commands::text_edit::active_editor_snapshot(self);
|
||||
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo = undo;
|
||||
self.history.redo = redo;
|
||||
self.history.branches = branches;
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
self.sanitize_after_replacement(edited.as_ref());
|
||||
self.bump();
|
||||
true
|
||||
}
|
||||
|
||||
+139
-7
@@ -394,6 +394,15 @@ pub struct Session {
|
||||
pub project: Arc<Project>,
|
||||
pub history: History,
|
||||
pub revision: u64,
|
||||
/// Process-unique monotonic revision of the on-canvas editor's committed text and session.
|
||||
/// Unlike [`Session::revision`], unrelated project edits do not advance it; the IME receipt
|
||||
/// uses it when surrounding text is unavailable. Read it with
|
||||
/// [`commands::text_edit::ime_stamp_revision`].
|
||||
///
|
||||
/// Stamps come from a process-global checked allocator and are never rewound: a failed
|
||||
/// atomic batch keeps the highest intermediate stamp. On allocator exhaustion no stamp is
|
||||
/// issued, so an unavailable receipt cannot be accepted with a reused revision.
|
||||
pub ime_revision: u64,
|
||||
pub saved_revision: u64,
|
||||
/// The project as last opened or saved: the project is unmodified while it is this very
|
||||
/// snapshot, so undoing back to the saved state clears the modified mark.
|
||||
@@ -521,6 +530,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`]).
|
||||
@@ -560,6 +571,8 @@ impl Default for Session {
|
||||
project,
|
||||
history: History::default(),
|
||||
revision: 0,
|
||||
// A fresh session gets its own process-monotonic stamp even before its first edit.
|
||||
ime_revision: commands::text_edit::next_ime_revision().unwrap_or(u64::MAX),
|
||||
saved_revision: 0,
|
||||
path: None,
|
||||
state: EditorState { snapping: true, view_layout: 1, ..Default::default() },
|
||||
@@ -618,6 +631,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(),
|
||||
}
|
||||
@@ -683,7 +697,25 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step.
|
||||
///
|
||||
/// Every edit is an external boundary for the on-canvas text editor: if it replaced the
|
||||
/// document the editor is showing, the selection is clamped and the editor gets a fresh IME
|
||||
/// epoch. The editor's own typing goes through `edit_text_doc`, which owns that
|
||||
/// document and must not treat its own keystrokes as a replacement.
|
||||
pub fn edit<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let out = self.edit_inner(label, merge, f)?;
|
||||
commands::text_edit::after_external_change(self, edited.as_ref());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`Session::edit`] for the on-canvas editor's own text changes. Its document is the one
|
||||
/// the editor owns, so a change here is typing, not a replacement behind the IME's back.
|
||||
pub(crate) fn edit_text_doc<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
||||
self.edit_inner(label, merge, f)
|
||||
}
|
||||
|
||||
fn edit_inner<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
||||
let before = self.project.clone();
|
||||
let mut p = (*self.project).clone();
|
||||
let mut st = self.state.clone();
|
||||
@@ -730,18 +762,61 @@ impl Session {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Note a change to the on-canvas editor's committed text or session.
|
||||
///
|
||||
/// Frontends compare this with [`commands::text_edit::ime_stamp_revision`]; an unrelated
|
||||
/// project change must not advance it.
|
||||
pub(crate) fn note_text_edit_change(&mut self) {
|
||||
// Checked process-wide allocation: a stamp is never wrapped or reused. On exhaustion
|
||||
// keep the previous value; an unavailable before/after pair then compares equal and
|
||||
// earns no receipt instead of accepting a stale revision.
|
||||
if let Some(revision) = commands::text_edit::next_ime_revision() {
|
||||
self.ime_revision = self.ime_revision.max(revision);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bump(&mut self) {
|
||||
self.revision += 1;
|
||||
// Back at the saved state (undo, redo, a history jump or a rolled-back batch).
|
||||
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`]).
|
||||
@@ -772,26 +847,48 @@ impl Session {
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.undo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.redo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
self.sanitize_after_replacement(edited.as_ref());
|
||||
self.bump();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.redo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
self.sanitize_after_replacement(edited.as_ref());
|
||||
self.bump();
|
||||
true
|
||||
}
|
||||
|
||||
/// Keep selections valid after the project was replaced by another state (undo, redo, a
|
||||
/// history jump, a rollback): the same layer id can name a different document, so the
|
||||
/// on-canvas editor gets a fresh IME epoch only when its own document changed.
|
||||
///
|
||||
/// Normally `before` is [`commands::text_edit::active_editor_snapshot`] taken from the
|
||||
/// state this call is about to replace. Atomic batch rollback is the exception: it has
|
||||
/// already restored the pre-batch state and passes the snapshot of the failing intermediate
|
||||
/// state, so a transient restart or component switch inside the batch still rejects reviving
|
||||
/// the old composition. A restored state that still has the same composition, layer and
|
||||
/// editor session and shows byte/style-equivalent text (an opacity or other property undo)
|
||||
/// keeps the editor's owner and selection; a different document, comp, layer or session clamps the
|
||||
/// selection and starts a new epoch. A closed editor stays closed, and a fallback comp's
|
||||
/// matching layer id never inherits the old comp's session. `None` means no editor was open
|
||||
/// when the snapshot was taken; if one is open after the replacement it is treated as an
|
||||
/// unverified document replacement, so it never inherits an old composition.
|
||||
pub fn sanitize_after_replacement(&mut self, before: Option<&commands::text_edit::EditorSnapshot>) {
|
||||
self.sanitize_state();
|
||||
commands::text_edit::after_replacement(self, before);
|
||||
}
|
||||
|
||||
/// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id
|
||||
/// counts only inside the comp that owns it, so the active comp never sees another comp's layers.
|
||||
pub fn sanitize_state(&mut self) {
|
||||
@@ -808,6 +905,8 @@ impl Session {
|
||||
});
|
||||
if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) {
|
||||
self.clear_live_selection();
|
||||
// A matching layer id in the fallback comp is not the document being edited.
|
||||
self.state.text_edit = None;
|
||||
self.state.active_comp = self.state.open_comps.first().copied();
|
||||
if let Some(id) = self.state.active_comp {
|
||||
self.recall_selection(id);
|
||||
@@ -867,13 +966,22 @@ impl Session {
|
||||
self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO)
|
||||
}
|
||||
|
||||
/// Move the active comp's current time.
|
||||
///
|
||||
/// Time is an external boundary for the on-canvas text editor too: an animated Source Text
|
||||
/// can show a different document at another time. The document is snapshotted only while an
|
||||
/// editor is open, so playback with no text editor does no text copying.
|
||||
pub fn set_time(&mut self, t: Tick) {
|
||||
if let Some(c) = self.state.active_comp {
|
||||
let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t));
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
self.state.times.insert(c, t);
|
||||
if self.prefs.general.sync_time_related_items {
|
||||
self.sync_related_times(c, t);
|
||||
// Direct call: this `set_time` owns the editor snapshot for both time changes,
|
||||
// so a synced move does not take a second snapshot or start a second epoch.
|
||||
self.sync_related_times_inner(c, t);
|
||||
}
|
||||
commands::text_edit::after_external_change(self, edited.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,6 +989,14 @@ impl Session {
|
||||
/// `comp` moves the current time of the comps nested in it (through their precomp layers'
|
||||
/// timing) and of the comps it is nested in, so every related viewer shows the same moment.
|
||||
pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) {
|
||||
// Moving a related comp can move the active one, so the editor's document is checked
|
||||
// here too; the snapshot is skipped entirely when no text editor is open.
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
self.sync_related_times_inner(comp, t);
|
||||
commands::text_edit::after_external_change(self, edited.as_ref());
|
||||
}
|
||||
|
||||
fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) {
|
||||
let mut seen = std::collections::BTreeSet::from([comp]);
|
||||
let mut todo = vec![(comp, t)];
|
||||
let mut found = vec![];
|
||||
@@ -924,6 +1040,9 @@ impl Session {
|
||||
self.state.active_comp = Some(id);
|
||||
self.recall_selection(id);
|
||||
self.state.current_track = None;
|
||||
// A text-edit session belongs to the comp that owned it. Switching comps ends it; even
|
||||
// a same-numbered layer in the new comp must not inherit the old IME composition.
|
||||
self.state.text_edit = None;
|
||||
}
|
||||
|
||||
/// Leave no comp active, keeping the one that was active in [`EditorState::comp_selections`].
|
||||
@@ -932,6 +1051,7 @@ impl Session {
|
||||
self.state.active_comp = None;
|
||||
self.clear_live_selection();
|
||||
self.state.current_track = None;
|
||||
self.state.text_edit = None;
|
||||
}
|
||||
|
||||
fn remember_selection(&mut self) {
|
||||
@@ -1036,12 +1156,18 @@ impl Session {
|
||||
|
||||
/// Render a comp frame with the session's footage + expression hosts.
|
||||
pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image {
|
||||
self.renderer(opts).comp_frame(comp, t)
|
||||
}
|
||||
|
||||
/// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and
|
||||
/// accelerator, and the render prefs (nested switches, draft shadows) over `opts`.
|
||||
pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> {
|
||||
let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts };
|
||||
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
|
||||
r.expr = self.expr.as_deref();
|
||||
r.cache = Some(&self.layer_cache);
|
||||
r.accel = self.accel.as_deref();
|
||||
r.comp_frame(comp, t)
|
||||
r
|
||||
}
|
||||
|
||||
/// Resolve a comp reference (item id number or name; `None`/null = the active comp).
|
||||
@@ -1169,6 +1295,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;
|
||||
@@ -1242,6 +1370,8 @@ mod tests_codec_options;
|
||||
#[cfg(test)]
|
||||
mod tests_color_view;
|
||||
#[cfg(test)]
|
||||
mod tests_crypto_pick;
|
||||
#[cfg(test)]
|
||||
mod tests_delete_keys;
|
||||
#[cfg(test)]
|
||||
mod tests_disk_cache;
|
||||
@@ -1290,6 +1420,8 @@ mod tests_proxy;
|
||||
#[cfg(test)]
|
||||
mod tests_quick_offset;
|
||||
#[cfg(test)]
|
||||
mod tests_replace_source;
|
||||
#[cfg(test)]
|
||||
mod tests_rig3d;
|
||||
#[cfg(test)]
|
||||
mod tests_sequence;
|
||||
|
||||
@@ -93,28 +93,34 @@ fn thread_name() -> String {
|
||||
return "main".to_string();
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. A clock set
|
||||
/// absurdly far ahead keeps its real year (preserved) instead of reading as the epoch.
|
||||
fn timestamp(ms: u64) -> String {
|
||||
let secs = ms / 1000;
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000)
|
||||
let (secs, millis) = (ms / 1000, (ms % 1000) as u32);
|
||||
let (y, t) = utc_parts(secs, millis * 1_000_000);
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most
|
||||
/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719_468;
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + u64::from(m <= 2);
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
|
||||
@@ -358,7 +364,12 @@ mod tests {
|
||||
assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z");
|
||||
let _ = timestamp(u64::MAX);
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(253_402_300_800_000), "10000-01-01T00:00:00.000Z");
|
||||
// A clock absurdly far ahead keeps its real year (years far outside chrono's own range are
|
||||
// preserved) instead of collapsing to the epoch (reviewer rule from the sibling PRs).
|
||||
assert_eq!(timestamp(10_000_000_000_000_000), "318857-05-20T17:46:40.000Z");
|
||||
assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z");
|
||||
}
|
||||
|
||||
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
|
||||
|
||||
@@ -264,22 +264,33 @@ pub fn parent(dir: &str) -> Option<String> {
|
||||
std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// Format seconds since the epoch as an ISO 8601 UTC date-time.
|
||||
/// Format seconds since the epoch as an ISO 8601 UTC date-time. Years far outside chrono's own
|
||||
/// range are preserved (the old hand-rolled conversion kept them) rather than collapsing to the epoch.
|
||||
pub fn iso_date(secs: u64) -> String {
|
||||
let days = (secs / 86_400) as i64;
|
||||
let rem = secs % 86_400;
|
||||
// Civil-from-days (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
|
||||
let (y, t) = utc_parts(secs);
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%SZ"))
|
||||
}
|
||||
|
||||
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
|
||||
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
|
||||
/// month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days`
|
||||
/// always fits an `i64`.
|
||||
fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// File dates and size: (created, modified, size).
|
||||
@@ -515,5 +526,10 @@ mod tests {
|
||||
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z");
|
||||
assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z");
|
||||
// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept
|
||||
// them) rather than collapsing to the epoch.
|
||||
assert_eq!(iso_date(253_402_300_800), "10000-01-01T00:00:00Z");
|
||||
assert_eq!(iso_date(10_000_000_000_000), "318857-05-20T17:46:40Z");
|
||||
assert_eq!(iso_date(u64::MAX), "584554051223-11-09T07:00:15Z");
|
||||
}
|
||||
}
|
||||
@@ -733,6 +733,7 @@ Layer
|
||||
Shape Layer | layer.newShape
|
||||
Adjustment Layer | layer.newAdjustment
|
||||
Content-Aware Fill Layer... | layer.newContentAwareFill
|
||||
3D Primitive... | layer.newPrimitive
|
||||
Cube | layer.new3dPrimitive {"kind":"cube"}
|
||||
Sphere | layer.new3dPrimitive {"kind":"sphere"}
|
||||
Plane | layer.new3dPrimitive {"kind":"plane"}
|
||||
|
||||
@@ -26,16 +26,19 @@ pub const PREFS_FILE: &str = "prefs.json";
|
||||
pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("Match System", "system"),
|
||||
("English", "en"),
|
||||
// Alphabetical by the language's own name after English; Cyrillic, Georgian, then CJK names
|
||||
// follow the Latin ones.
|
||||
("Čeština", "cs"),
|
||||
("Deutsch", "de"),
|
||||
("Español", "es"),
|
||||
("Français", "fr"),
|
||||
("Português (Brasil)", "pt-br"),
|
||||
("Русский", "ru"),
|
||||
("Українська", "uk"),
|
||||
("ქართული", "ka"),
|
||||
("日本語", "ja"),
|
||||
("简体中文", "zh-hans"),
|
||||
("繁體中文", "zh-hant"),
|
||||
("Українська", "uk"),
|
||||
("Español", "es"),
|
||||
("Português (Brasil)", "pt-br"),
|
||||
("Русский", "ru"),
|
||||
("Français", "fr"),
|
||||
("Deutsch", "de"),
|
||||
("Čeština", "cs"),
|
||||
];
|
||||
|
||||
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
|
||||
@@ -110,7 +113,8 @@ page!(General {
|
||||
/// Synchronize Time of All Related Items.
|
||||
sync_time_related_items: bool = true,
|
||||
expression_pick_whip_compact: bool = true,
|
||||
create_split_layers_above: bool = false,
|
||||
/// Create Split Layers Above Original Layer (After Effects' default).
|
||||
create_split_layers_above: bool = true,
|
||||
use_system_color_picker: bool = false,
|
||||
/// Number of projects in File ▸ Open Recent.
|
||||
recent_items: u32 = 10,
|
||||
|
||||
@@ -263,6 +263,8 @@ pub struct FrameServer {
|
||||
/// The disk cache's layer buffers (see the module docs) and the project's salt.
|
||||
layer_store: Option<Arc<PrefetchStore>>,
|
||||
salt: u64,
|
||||
/// Size and content hash for footage paths with no native metadata (the browser file table).
|
||||
virtual_id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>,
|
||||
}
|
||||
|
||||
/// A frame whose readbacks are in flight.
|
||||
@@ -294,9 +296,16 @@ impl FrameServer {
|
||||
pending: None,
|
||||
layer_store: None,
|
||||
salt: 0,
|
||||
virtual_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of virtual footage files, used when native metadata cannot describe the path.
|
||||
/// Called from [`FrameServer::load`], not from the UI thread's cache lookup.
|
||||
pub fn set_virtual_id(&mut self, id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>) {
|
||||
self.virtual_id = id;
|
||||
}
|
||||
|
||||
/// The store behind the layer cache when a request asks for `layers` (the browser: the
|
||||
/// disk cache's layer buffers, fetched before the request).
|
||||
pub fn set_layer_store(&mut self, store: Option<Arc<PrefetchStore>>) {
|
||||
@@ -338,7 +347,7 @@ impl FrameServer {
|
||||
|
||||
fn load(&mut self, revision: u64) -> Result<(), String> {
|
||||
let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?;
|
||||
self.salt = effectcraft_render::disk_cache::footage_salt(&p);
|
||||
self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref());
|
||||
self.cache.set_store_salt(self.salt);
|
||||
self.project = Some(Arc::new(p));
|
||||
self.revision = Some(revision);
|
||||
|
||||
@@ -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)
|
||||
@@ -116,6 +116,11 @@ impl Session {
|
||||
self.layer_cache.set_disk(None);
|
||||
return;
|
||||
};
|
||||
// Tests: the Roto/Object Matte segment store below is one per process, so a test that
|
||||
// opens a disk cache (often the platform folder, through a settings store) would take
|
||||
// over the store of a roto test running beside it. Serialise them with the roto tests.
|
||||
#[cfg(test)]
|
||||
crate::tests_roto::hold_roto_cache_test_lock();
|
||||
let dc = match &self.disk_cache {
|
||||
Some(c) if c.folder() == folder.as_path() => {
|
||||
c.set_max_bytes(max);
|
||||
@@ -131,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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! `effect.pickCryptomatte` / `effect.cryptomatteAt` (#473): the Cryptomatte eyedropper reads the
|
||||
//! aux channels the effect renders from (footage at its source time, a 3D precomp's generated
|
||||
//! pass) and edits the Selection text in one undo step.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId};
|
||||
use effectcraft_raster::channels3d::{crypto_float, crypto_hash};
|
||||
use effectcraft_raster::{AuxChannels, Image};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::FootageSource;
|
||||
|
||||
const COMMA: &str = "Green, \"Glossy\"";
|
||||
|
||||
/// 8 × 4 footage with a 16 × 8 Cryptomatte (two aux pixels per layer pixel). Layer pixels in x:
|
||||
/// 0-1 `left` (0.6) over `right` (0.4); 2-3 `left` (0.3) under the comma name (0.7), ranks out of
|
||||
/// order; 4-5 `star*`; 6 an ID missing from the manifest; 7 a 0.5 / 0.5 tie of `left` and
|
||||
/// `right`. Rows 3+ are empty. From 1 s of source time `late` replaces columns 0-1.
|
||||
struct Crypto;
|
||||
|
||||
fn aux_at(t: Tick) -> AuxChannels {
|
||||
let (w, h) = (16usize, 8usize);
|
||||
let mut planes = vec![vec![0.0f32; w * h]; 4];
|
||||
for (i, x) in (0..w * h).map(|i| (i, i % w)).filter(|(i, _)| i / w < 6) {
|
||||
let late = if t >= Tick::from_seconds_f64(1.0) { "late" } else { "left" };
|
||||
let (a, ca, b, cb) = match x {
|
||||
0..4 => (late, 0.6, "right", 0.4),
|
||||
4..8 => ("left", 0.3, COMMA, 0.7),
|
||||
8..12 => ("star*", 1.0, "", 0.0),
|
||||
12..14 => ("ghost", 1.0, "", 0.0),
|
||||
_ => ("left", 0.5, "right", 0.5),
|
||||
};
|
||||
for (k, v) in [crypto_float(crypto_hash(a)), ca, crypto_float(crypto_hash(b)), cb].into_iter().enumerate() {
|
||||
planes[k][i] = v;
|
||||
}
|
||||
}
|
||||
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
for (k, c) in ["R", "G", "B", "A"].iter().enumerate() {
|
||||
aux.channels.push((format!("CryptoObject00.{c}"), planes[k].clone()));
|
||||
}
|
||||
aux.manifests = vec![("CryptoObject".into(), ["left", "right", "late", COMMA, "star*"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())];
|
||||
aux
|
||||
}
|
||||
|
||||
impl FootageSource for Crypto {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
Some(Arc::new(aux_at(t)))
|
||||
}
|
||||
}
|
||||
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
let duration = Tick::from_seconds_f64(2.0);
|
||||
// A half-size proxy of the EXR.
|
||||
let (width, height) = if path.contains("proxy") { (4, 2) } else { (8, 4) };
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: FootageKind::Video,
|
||||
width,
|
||||
height,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(24.0),
|
||||
duration,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { footage: Arc::new(Crypto), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn pick(s: &mut Session, x: f64, mode: &str) -> Value {
|
||||
s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1.5, "mode": mode})).unwrap()
|
||||
}
|
||||
|
||||
fn selection(s: &Session) -> String {
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
let g = l.effects().unwrap().groups().next().unwrap();
|
||||
match &g.prop("selection").unwrap().value {
|
||||
effectcraft_keyframe::Value::Str(t) => t.clone(),
|
||||
v => panic!("{v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_replace_add_and_remove_in_one_undo_step_each() {
|
||||
let mut s = session();
|
||||
let steps = s.history.undo.len();
|
||||
let r = pick(&mut s, 0.5, "replace");
|
||||
assert_eq!((r["name"].as_str(), r["selection"].as_str(), r["changed"].as_bool()), (Some("left"), Some("left"), Some(true)), "{r}");
|
||||
assert_eq!(r["hash"], format!("{:08x}", crypto_hash("left")));
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
// The highest coverage wins even when it is not rank 0; the name is quoted.
|
||||
assert_eq!(pick(&mut s, 2.5, "add")["selection"], r#"left, "Green, \"Glossy\"""#);
|
||||
// Adding it again changes nothing and leaves no undo step.
|
||||
let again = pick(&mut s, 3.5, "add");
|
||||
assert_eq!((again["changed"].as_bool(), s.history.undo.len()), (Some(false), steps + 2));
|
||||
// A `*` name and an unnamed ID go in as hashes; a tie goes to rank 0.
|
||||
pick(&mut s, 4.5, "add");
|
||||
assert_eq!(pick(&mut s, 6.5, "add")["selection"], format!(r#"left, "Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost")));
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 7.5, "y": 0.5})).unwrap()["name"], "left");
|
||||
let r = pick(&mut s, 0.0, "remove");
|
||||
assert_eq!(r["selection"], format!(r#""Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost")));
|
||||
assert_eq!(r["selected"], false);
|
||||
// The text parses back to exactly the picked IDs.
|
||||
let manifest = aux_at(Tick::ZERO).manifests[0].1.clone();
|
||||
let got = effectcraft_effects::selection_hashes(&selection(&s), Some(&manifest));
|
||||
assert_eq!(got, [COMMA, "star*", "ghost"].map(crypto_hash));
|
||||
assert_eq!(s.history.undo.len(), steps + 5);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(selection(&s).starts_with("left, "), "{}", selection(&s));
|
||||
pick(&mut s, 2.0, "replace");
|
||||
assert_eq!(selection(&s), r#""Green, \"Glossy\"""#);
|
||||
// The same selection with other spacing: no change, no undo step.
|
||||
s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": " \"Green, \\\"Glossy\\\"\" "})).unwrap();
|
||||
let n = s.history.undo.len();
|
||||
assert_eq!((pick(&mut s, 2.0, "replace")["changed"].as_bool(), s.history.undo.len()), (Some(false), n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_points_layers_and_modes_are_errors_and_change_nothing() {
|
||||
let mut s = session();
|
||||
let at = |s: &mut Session, x: f64, y: f64| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": y}));
|
||||
for (x, y) in [(-0.1, 1.0), (8.0, 1.0), (1.0, 4.0), (1.0, 3.5), (1e300, -1e300)] {
|
||||
assert!(at(&mut s, x, y).is_err(), "{x},{y}");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": x, "y": y})).unwrap(), Value::Null);
|
||||
}
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "mode": "toggle"})).is_err());
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1})).is_err());
|
||||
for scale in [0.0, -1.0, 2.0] {
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "scale": scale})).is_err(), "{scale}");
|
||||
}
|
||||
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 2, "x": 1, "y": 1})).is_err(), "not a Cryptomatte effect");
|
||||
assert_eq!(selection(&s), "");
|
||||
// A layer without aux channels.
|
||||
s.execute("layer.newSolid", json!({"name": "S"})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let e = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("no Cryptomatte channels"), "{e}");
|
||||
// An expression-driven Selection would ignore the edit.
|
||||
let mut s = session();
|
||||
s.execute_checked("prop.setExpression", json!({"path": "effects/#1/selection", "expression": "\"right\""})).unwrap();
|
||||
assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).is_err());
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 1, "y": 1})).unwrap()["selected"], false);
|
||||
}
|
||||
|
||||
/// The pick reads the frame the render shows: Time Remap's source time, not the layer time.
|
||||
#[test]
|
||||
fn time_remap_picks_the_shown_source_frame() {
|
||||
let mut s = session();
|
||||
assert_eq!(pick(&mut s, 0.5, "replace")["name"], "left");
|
||||
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap();
|
||||
assert_eq!(pick(&mut s, 0.5, "replace")["name"], "late");
|
||||
}
|
||||
|
||||
/// A precomp of a 3D comp: the generated CryptoObject pass names its layers.
|
||||
#[test]
|
||||
fn picks_on_a_precomp_of_a_3d_comp() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Red, Ball", "width": 40, "height": 20})).unwrap()["layer"].clone();
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 20, "y": 10})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["selection"].as_str()), (Some("Red, Ball"), Some("\"Red, Ball\"")), "{r}");
|
||||
}
|
||||
|
||||
/// The matte the rendered Cryptomatte effect gives the picked ID at the buffer pixel under
|
||||
/// `{x, y}`: the pick has to name the ID the effect's own input buffer samples there.
|
||||
fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (String, f32) {
|
||||
let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale, "mode": "replace"})).unwrap();
|
||||
let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().position(|g| g.match_id == "ec.3d.cryptomatte").unwrap() + 1;
|
||||
s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap();
|
||||
let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap());
|
||||
let layer = &comp.layers[0];
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None };
|
||||
let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap();
|
||||
let (px, py) = buf.to_px([x, y]);
|
||||
let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0];
|
||||
(r["name"].as_str().unwrap_or("?").to_string(), m)
|
||||
}
|
||||
|
||||
/// Between 3/4 and full resolution footage keeps its native pixels: at 0.8 the effect samples
|
||||
/// layer pixel 2 (the comma name, 0.7) at x = 2.2, not the 0.8-grid pixel snapped back to 1.
|
||||
#[test]
|
||||
fn pick_at_scale_0_8_matches_the_native_buffer_the_effect_renders() {
|
||||
let mut s = session();
|
||||
let (name, m) = rendered_matte_of_pick(&mut s, 2.2, 1.5, 0.8);
|
||||
assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there");
|
||||
assert_eq!(name, COMMA);
|
||||
}
|
||||
|
||||
/// A half-size proxy renders a buffer at 0.5 layer pixels per pixel even at full resolution:
|
||||
/// x = 6.5 lies in buffer pixel 3, which samples layer x = 7 (the `left` / `right` tie).
|
||||
#[test]
|
||||
fn pick_on_a_proxy_matches_the_proxy_sized_buffer() {
|
||||
let mut s = session();
|
||||
let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0;
|
||||
s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/crypto_proxy.exr"})).unwrap();
|
||||
let (name, m) = rendered_matte_of_pick(&mut s, 6.5, 1.5, 1.0);
|
||||
assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there");
|
||||
assert_eq!(name, "left");
|
||||
}
|
||||
|
||||
/// 1920-px footage with `left` up to x = 120 and `right` after it, and a 104-px proxy.
|
||||
struct Edge;
|
||||
impl FootageSource for Edge {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
|
||||
let (w, h) = (1920usize, 40usize);
|
||||
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
let ids = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < 120 { "left" } else { "right" }))).collect();
|
||||
aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])];
|
||||
aux.manifests = vec![("CryptoObject".into(), ["left", "right"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())];
|
||||
Some(Arc::new(aux))
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy buffer pixel 6's centre maps to layer x = 6.5 / (104 / 1920): 120.0 by division, but
|
||||
/// 119.99999999999999 by the reciprocal the effect multiplies with. The pick must agree.
|
||||
#[test]
|
||||
fn pick_maps_pixel_centres_bit_exactly_as_the_effect() {
|
||||
let mut s = Session { footage: Arc::new(Edge), ..Default::default() };
|
||||
let at = |path: &str, width| Footage {
|
||||
path: path.into(),
|
||||
kind: FootageKind::Video,
|
||||
width,
|
||||
height: 40,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(24.0),
|
||||
duration: Tick::from_seconds_f64(2.0),
|
||||
..Default::default()
|
||||
};
|
||||
s.importer = Some(Arc::new(EdgeProbe(at("/renders/edge.exr", 1920), at("/renders/edge_proxy.exr", 104))));
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/edge.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 1920, "height": 40, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
s.execute("file.setProxy", json!({"item": item, "path": "/renders/edge_proxy.exr"})).unwrap();
|
||||
let (name, m) = rendered_matte_of_pick(&mut s, 118.0, 5.0, 1.0);
|
||||
assert_eq!((name.as_str(), m), ("left", 1.0));
|
||||
}
|
||||
|
||||
struct EdgeProbe(Footage, Footage);
|
||||
impl crate::Importer for EdgeProbe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
Ok(if path.contains("proxy") { self.1.clone() } else { self.0.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage whose decoded frames are `num / den` of the probed size (a file re-rendered at another
|
||||
/// resolution, a relinked file): the render stretches them over the layer.
|
||||
struct OffNominal(u32, u32);
|
||||
impl FootageSource for OffNominal {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width * self.0 / self.1, f.height * self.0 / self.1)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
Some(Arc::new(aux_at(t)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Points along rows `ys` where the picked ID isn't lit in the rendered matte under the point.
|
||||
fn mismatches(s: &mut Session, scales: &[f64], xs: impl Iterator<Item = f64> + Clone, ys: &[f64]) -> Vec<String> {
|
||||
let mut bad = vec![];
|
||||
for &scale in scales {
|
||||
for (x, &y) in ys.iter().flat_map(|y| xs.clone().map(move |x| (x, y))) {
|
||||
if s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale})).unwrap().is_null() {
|
||||
continue;
|
||||
}
|
||||
let (name, m) = rendered_matte_of_pick(s, x, y, scale);
|
||||
if m < 0.5 {
|
||||
bad.push(format!("scale {scale} ({x:.2}, {y}): picked {name:?}, matte {m}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
bad
|
||||
}
|
||||
|
||||
const PREVIEW_SCALES: [f64; 7] = [1.0, 0.8, 0.75, 0.74, 0.5, 0.33, 0.25];
|
||||
|
||||
/// The pick follows the decoded frame's real size, not the footage's nominal one.
|
||||
#[test]
|
||||
fn pick_matches_the_render_when_frames_decode_off_nominal_size() {
|
||||
for (num, den) in [(2, 1), (1, 2), (3, 4)] {
|
||||
let mut s = Session { footage: Arc::new(OffNominal(num, den)), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..80).map(|i| i as f64 * 0.1 + 0.05), &[0.3, 1.7, 2.6]);
|
||||
assert!(bad.is_empty(), "decode {num}/{den}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A precomp of a 3D comp, with and without Preserve Resolution (drawn at full size below full
|
||||
/// resolution), behind a disabled effect: the pick names the ID the rendered matte shows.
|
||||
#[test]
|
||||
fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() {
|
||||
for preserve in [false, true] {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 2})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name": "A", "width": 40, "height": 20})).unwrap()["layer"].clone();
|
||||
let b = s.execute("layer.newSolid", json!({"name": "B", "width": 7, "height": 20})).unwrap()["layer"].clone();
|
||||
s.execute_checked("prop.set", json!({"layer": b, "path": "transform/position", "value": [3.5, 10, -10]})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
|
||||
let inner = *s.project.items.iter().find(|(_, i)| i.name == "Inner").unwrap().0;
|
||||
s.execute_checked("comp.settings", json!({"comp": inner.0, "preserveResolution": preserve})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute_checked("effect.toggle", json!({"effect": 1, "value": false})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..93).map(|i| i as f64 * 0.15 + 0.05), &[10.0]);
|
||||
assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
}
|
||||
@@ -772,6 +772,27 @@ fn preset_round_trips_effect_properties_by_match_name() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
|
||||
/// `essential.list` reports.
|
||||
#[test]
|
||||
fn add_key_resolves_effect_match_name_paths() {
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Slider Control");
|
||||
apply(&mut s, a, "Slider Control");
|
||||
let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value {
|
||||
KV::Scalar(v) => v,
|
||||
ref other => panic!("not a scalar: {other:?}"),
|
||||
};
|
||||
for (path, n, value) in [("effects/ec.control.slider/slider", 0, 30.0), ("effects/ec.control.slider#2/slider", 1, 70.0)] {
|
||||
let reply = s.execute("prop.addKey", json!({"layer": a, "path": path, "time": 0, "value": value})).unwrap();
|
||||
assert_eq!(reply, json!(1), "{path}");
|
||||
let got = s.execute("prop.get", json!({"layer": a, "path": path, "time": 0})).unwrap();
|
||||
assert_eq!(got["value"], json!(value), "{path}");
|
||||
assert_eq!(got["keys"].as_array().unwrap().len(), 1, "{path}");
|
||||
assert_eq!(slider(&s, n), 0.0, "the static value stays put; the key carries {value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path,
|
||||
/// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect.
|
||||
#[test]
|
||||
|
||||
@@ -57,7 +57,10 @@ fn default_layer_times_are_preserved_and_outside_keys_warn() {
|
||||
assert!(selected.get("warning").is_some());
|
||||
let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap();
|
||||
assert_seconds(&moved["compTime"], 20.0);
|
||||
assert!(moved.get("warning").is_some(), "out point is exclusive");
|
||||
assert!(moved.get("warning").is_none(), "a key on the out point is accepted");
|
||||
let past = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": 20.1, "timeBase": "comp", "value": 45})).unwrap();
|
||||
assert_seconds(&past["compTime"], 20.1);
|
||||
assert!(past.get("warning").is_some(), "a key past the out point warns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() {
|
||||
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
|
||||
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() {
|
||||
// Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A
|
||||
// contains C walked the A ⇄ B cycle until the stack ran out (#1237).
|
||||
let mut s = Session::default();
|
||||
let mut ids = vec![];
|
||||
for name in ["A", "B", "C"] {
|
||||
s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
|
||||
ids.push(s.active_comp_id().unwrap());
|
||||
}
|
||||
let (a, b, c) = (ids[0], ids[1], ids[2]);
|
||||
{
|
||||
let proj = std::sync::Arc::make_mut(&mut s.project);
|
||||
for (outer, inner) in [(a, b), (b, a)] {
|
||||
let comp = proj.comp(outer).unwrap().clone();
|
||||
let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None);
|
||||
let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") };
|
||||
std::sync::Arc::make_mut(cc).layers.push(l);
|
||||
}
|
||||
}
|
||||
let path = tmp("cycle.ecproj");
|
||||
std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
t.open_comp(c);
|
||||
// Adding A into C asks whether A contains C: it doesn't, so the layer goes in.
|
||||
let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap();
|
||||
assert!(r["layer"].as_u64().is_some(), "{r}");
|
||||
// Adding C into A would nest C in itself through A: refused, as before.
|
||||
assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err());
|
||||
// C renders: inside A the renderer skips B's copy of A instead of looping.
|
||||
let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!((img.width, img.height), (32, 32));
|
||||
}
|
||||
@@ -42,14 +42,14 @@ fn split_layers_go_above_or_below_the_original() {
|
||||
let mut s = session();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
|
||||
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![a, below], "off (default): the new part goes below");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap();
|
||||
let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
|
||||
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![above, a]);
|
||||
assert_eq!(order, vec![above, a], "on (default): the new part goes above");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": false})).unwrap();
|
||||
let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
|
||||
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![a, below]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -111,6 +111,29 @@ fn set_switch_others() {
|
||||
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
|
||||
}
|
||||
|
||||
/// The Video switch has a cell on every layer's row, cameras and lights included, so Alt-clicking
|
||||
/// it (and Hide Other Video) takes them along with the rest.
|
||||
#[test]
|
||||
fn video_others_takes_cameras_and_lights() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, cam, light].map(|l| f(&layer(s, l)));
|
||||
// The camera and the light are hidden: Alt-click on the solid's eye brings them back, since
|
||||
// they stand apart with it, and the next Alt-click hides them with every other layer.
|
||||
s.execute("layer.setSwitch", json!({"layers": [cam, light], "switch": "video", "value": false})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
|
||||
// Hide Other Video takes them too.
|
||||
s.execute("layer.showAllVideo", json!({})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
|
||||
s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
|
||||
}
|
||||
|
||||
/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles,
|
||||
/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is
|
||||
/// hidden (parenting ignores the eye).
|
||||
@@ -725,6 +748,37 @@ fn reveal_properties() {
|
||||
assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_layer_source_selects_each_layers_source_item() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
let ia = layer(&s, a).source.item().unwrap();
|
||||
let ib = layer(&s, b).source.item().unwrap();
|
||||
assert_ne!(ia, ib);
|
||||
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||
let r = s.execute("layer.revealSource", json!({})).unwrap();
|
||||
assert_eq!(r["item"], ia.0);
|
||||
assert_eq!(r["items"], json!([ia.0, ib.0]));
|
||||
assert_eq!(s.state.project_selection, vec![ia, ib]);
|
||||
assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [ia.0, ib.0]}))]);
|
||||
// A null has no source. Alone it is refused; in a mixed selection only the sources are revealed.
|
||||
let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [n]})).unwrap();
|
||||
assert!(s.execute("layer.revealSource", json!({})).is_err());
|
||||
s.execute("layer.select", json!({"layers": [n, a]})).unwrap();
|
||||
let r = s.execute("layer.revealSource", json!({})).unwrap();
|
||||
assert_eq!(r["items"], json!([ia.0]));
|
||||
assert_eq!(s.state.project_selection, vec![ia]);
|
||||
// Reveal Composition in Project opens the same panel and folders.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.drain_events();
|
||||
let r = s.execute("comp.revealInProject", json!({"comp": cid.0})).unwrap();
|
||||
assert_eq!(r["item"], cid.0);
|
||||
assert_eq!(s.state.project_selection, vec![cid]);
|
||||
assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [cid.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_layers() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Layer ▸ Replace Source (#991, and the `replaceSource` part of #813): Alt-drag and Ctrl+Alt+/
|
||||
//! replace an AV layer's source while every other layer property stays.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Solid};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn comp() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
fn solid(s: &mut Session, name: &str) -> u64 {
|
||||
s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn add_footage(s: &mut Session, name: &str, kind: FootageKind, width: u32, height: u32, has_audio: bool) -> ItemId {
|
||||
let f = Footage { path: format!("/tmp/{name}"), kind, width, height, has_video: kind != FootageKind::Data, has_audio, ..Default::default() };
|
||||
Arc::make_mut(&mut s.project).add_item(name, Label::None, None, ItemKind::Footage(f))
|
||||
}
|
||||
|
||||
/// The replacement changes `source` and nothing else: name, label, comment, timing, stretch,
|
||||
/// switches, blending, parenting, track matte and the whole property tree (keys, expression,
|
||||
/// effect, mask) stay.
|
||||
#[test]
|
||||
fn replace_source_keeps_every_layer_property() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "Hero");
|
||||
let parent = solid(&mut s, "Parent");
|
||||
let matte = solid(&mut s, "Matte");
|
||||
s.execute("layer.timing", json!({"layers": [a], "in": 1.0, "out": 5.0})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"layers": [a], "percent": 50})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "preserveTransparency", "value": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap();
|
||||
s.execute("layer.setBlendMode", json!({"layers": [a], "mode": "Multiply"})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [a], "parent": parent, "compensate": false})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layer": a, "matte": matte, "kind": "alpha"})).unwrap();
|
||||
s.execute("layer.setComment", json!({"layers": [a], "comment": "keep me"})).unwrap();
|
||||
s.execute("effect.apply", json!({"layer": a, "effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute("mask.new", json!({"layer": a, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 1.0, "value": 25.0})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 2.0, "value": 75.0})).unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap();
|
||||
let before = layer(&s, a);
|
||||
let item = add_footage(&mut s, "new.png", FootageKind::Still, 120, 80, false);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
assert_eq!(after.source, LayerSource::Footage { item });
|
||||
assert_eq!(after.name, before.name);
|
||||
assert_eq!(after.label, before.label);
|
||||
assert_eq!(after.comment, before.comment);
|
||||
assert_eq!((after.start_time, after.in_point, after.out_point, after.stretch), (before.start_time, before.in_point, before.out_point, before.stretch));
|
||||
assert_eq!((after.switches, after.blend_mode, after.preserve_transparency), (before.switches, before.blend_mode, before.preserve_transparency));
|
||||
assert_eq!((after.parent, after.track_matte), (before.parent, before.track_matte));
|
||||
assert_eq!(after.props, before.props);
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 3, "no layer is added or removed");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).source, before.source, "one undo step restores the source");
|
||||
}
|
||||
|
||||
/// Composition, footage and solid sources are interchangeable; a precomposition adds its Audio
|
||||
/// group when the layer has none, and an existing Audio group is kept when the next source has no
|
||||
/// audio.
|
||||
#[test]
|
||||
fn replace_source_crosses_types_and_adds_audio() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "Layer");
|
||||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 200, "height": 100, "frameRate": 25, "duration": 3})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
|
||||
s.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
let before = layer(&s, a);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": inner})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
assert_eq!(after.source, LayerSource::Comp { item: ItemId(inner) });
|
||||
assert_eq!(after.name, before.name);
|
||||
assert!(after.props.sub("audio").is_some(), "a precomposition brings Audio");
|
||||
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 64, 32, false);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": footage.0})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
assert_eq!(after.source, LayerSource::Footage { item: footage });
|
||||
assert!(after.props.sub("audio").is_some(), "the Audio group is preserved for the next source");
|
||||
let new_solid = Arc::make_mut(&mut s.project).add_item(
|
||||
"New Solid",
|
||||
Label::None,
|
||||
None,
|
||||
ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 10, height: 10, pixel_aspect: 1.0 }),
|
||||
);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": new_solid.0})).unwrap();
|
||||
assert_eq!(layer(&s, a).source, LayerSource::Solid { item: new_solid });
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).source, LayerSource::Footage { item: footage });
|
||||
}
|
||||
|
||||
/// Folders, data files, 3D models, non-source layers and composition cycles are refused before
|
||||
/// anything is edited.
|
||||
#[test]
|
||||
fn replace_source_refuses_invalid_sources_targets_and_cycles() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "Layer");
|
||||
let folder = Arc::make_mut(&mut s.project).add_item("Folder", Label::None, None, ItemKind::Folder);
|
||||
let data = add_footage(&mut s, "data.txt", FootageKind::Data, 0, 0, false);
|
||||
let model = add_footage(&mut s, "model.glb", FootageKind::Model, 10, 10, false);
|
||||
for (item, needle) in [(folder, "folder"), (data, "data"), (model, "3D model")] {
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap_err().to_string();
|
||||
assert!(err.contains(needle), "{err}");
|
||||
}
|
||||
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
|
||||
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [text], "item": footage.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("no source item"), "{err}");
|
||||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
|
||||
s.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
let inner_layer = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [inner_layer], "item": main.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("contain"), "{err}");
|
||||
}
|
||||
|
||||
/// Ctrl+Alt+/ takes the source from the Project panel selection and replaces every selected
|
||||
/// layer; locked layers are left alone (and an all-locked selection is an error).
|
||||
#[test]
|
||||
fn replace_source_uses_the_project_selection_and_skips_locked_layers() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "A");
|
||||
let b = solid(&mut s, "B");
|
||||
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
|
||||
s.state.project_selection = vec![footage];
|
||||
let r = s.execute("layer.replaceSource", json!({"layers": [a, b]})).unwrap();
|
||||
assert_eq!(r["item"], json!(footage.0));
|
||||
assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
|
||||
assert!(matches!(layer(&s, b).source, LayerSource::Footage { item } if item == footage));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
|
||||
let old_b = layer(&s, b).source;
|
||||
let r = s.execute("layer.replaceSource", json!({"layers": [a, b], "item": footage.0})).unwrap();
|
||||
assert_eq!(r["layers"], json!([a]));
|
||||
assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
|
||||
assert_eq!(layer(&s, b).source, old_b);
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [b], "item": footage.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("locked"), "{err}");
|
||||
}
|
||||
|
||||
/// After Effects' Ctrl+Alt+/ default (Cmd+Alt+/ in EffectCraft's shortcut syntax).
|
||||
#[test]
|
||||
fn replace_source_is_bound_to_ctrl_alt_slash() {
|
||||
let b = crate::shortcuts::bindables(&[]).into_iter().find(|b| b.command == "layer.replaceSource").expect("layer.replaceSource is bindable");
|
||||
assert!(b.defaults.iter().any(|d| d == "Cmd+Alt+/"), "{:?}", b.defaults);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1064,6 +1064,12 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
||||
cs.load_settings();
|
||||
assert_eq!(cs.prefs.general.language, "cs");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
|
||||
// Georgian too.
|
||||
assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka")));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap();
|
||||
let mut ka = Session { config: Some(store.clone()), ..Default::default() };
|
||||
ka.load_settings();
|
||||
assert_eq!(ka.prefs.general.language, "ka");
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
let mut reloaded = Session { config: Some(store), ..Default::default() };
|
||||
reloaded.load_settings();
|
||||
|
||||
@@ -6,7 +6,7 @@ use effectcraft_keyframe::{BaselineOption, Justify, Kerning, TextDoc};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::commands::text_edit::layer_doc;
|
||||
use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc};
|
||||
|
||||
fn session(text: &str) -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
@@ -24,6 +24,98 @@ fn sel(s: &Session) -> (usize, usize) {
|
||||
(e.anchor, e.caret)
|
||||
}
|
||||
|
||||
/// Start editing the text layer with everything selected (no caret given).
|
||||
fn begin_edit(s: &mut Session, t: u64) {
|
||||
s.execute("text.edit", json!({"layer": t})).unwrap();
|
||||
}
|
||||
|
||||
/// Start editing the text layer with the caret at `caret`.
|
||||
fn begin_edit_at(s: &mut Session, t: u64, caret: usize) {
|
||||
s.execute("text.edit", json!({"layer": t, "caret": caret})).unwrap();
|
||||
}
|
||||
|
||||
/// A session with one text layer already open in the editor at `caret`.
|
||||
fn editing(text: &str, caret: usize) -> (Session, u64) {
|
||||
let (mut s, t) = session(text);
|
||||
begin_edit_at(&mut s, t, caret);
|
||||
(s, t)
|
||||
}
|
||||
|
||||
/// A session with one text layer already open in the editor with everything selected.
|
||||
fn editing_all(text: &str) -> (Session, u64) {
|
||||
let (mut s, t) = session(text);
|
||||
begin_edit(&mut s, t);
|
||||
(s, t)
|
||||
}
|
||||
|
||||
/// The IME owner token of the open editor.
|
||||
fn ime_owner(s: &Session) -> u64 {
|
||||
s.state.text_edit.as_ref().unwrap().session
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.history.undo.len()
|
||||
}
|
||||
|
||||
/// The editor kept its document (same IME owner).
|
||||
fn assert_ime_owner(s: &Session, expected: u64, why: &str) {
|
||||
assert_eq!(ime_owner(s), expected, "{why}");
|
||||
}
|
||||
|
||||
/// The editor's document was replaced (new IME owner/epoch).
|
||||
fn assert_ime_epoch_changed(s: &Session, previous: u64, why: &str) {
|
||||
assert_ne!(ime_owner(s), previous, "{why}");
|
||||
}
|
||||
|
||||
fn assert_text(s: &Session, t: u64, expected: &str) {
|
||||
assert_eq!(doc(s, t).text, expected);
|
||||
}
|
||||
|
||||
/// A new, empty composition called "Other" (the standard second comp).
|
||||
fn new_other_comp(s: &mut Session) -> u64 {
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap()["comp"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// Stopwatch the layer's Source Text (one key at the CTI).
|
||||
fn animate_source(s: &mut Session, t: u64) {
|
||||
s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap();
|
||||
}
|
||||
|
||||
/// Add a Source Text key at `time`.
|
||||
fn add_source_key(s: &mut Session, t: u64, time: f64, value: &str) {
|
||||
s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
|
||||
/// Write Source Text as a static value (a replacement when it differs from the shown text).
|
||||
fn set_source(s: &mut Session, t: u64, value: &str) {
|
||||
s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": value})).unwrap();
|
||||
}
|
||||
|
||||
/// Write the Source Text key at `time`.
|
||||
fn set_source_key(s: &mut Session, t: u64, time: f64, value: &str) {
|
||||
s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
|
||||
/// Set the Source Text key at the CTI (`keys.set`).
|
||||
fn set_source_cti_key(s: &mut Session, t: u64, time: f64, value: &str) {
|
||||
s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
|
||||
/// Replace the whole Source Text of the layer (`layer.setText`).
|
||||
fn replace_text(s: &mut Session, t: u64, text: &str) {
|
||||
s.execute("layer.setText", json!({"layer": t, "text": text})).unwrap();
|
||||
}
|
||||
|
||||
/// Select the character range `start..end` in the open editor.
|
||||
fn select(s: &mut Session, start: usize, end: usize) {
|
||||
s.execute("text.setSelection", json!({"start": start, "end": end})).unwrap();
|
||||
}
|
||||
|
||||
/// Set both selection ends, keeping their direction (`anchor` is the other end).
|
||||
fn select_anchor(s: &mut Session, anchor: usize, caret: usize) {
|
||||
s.execute("text.setSelection", json!({"anchor": anchor, "caret": caret})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_with_a_range_creates_runs_and_undoes() {
|
||||
let (mut s, t) = session("Hello World");
|
||||
@@ -200,7 +292,596 @@ fn edit_state_round_trips_through_serde() {
|
||||
s.execute("text.edit", json!({"layer": t, "select": [1, 2]})).unwrap();
|
||||
let j = serde_json::to_value(&s.state).unwrap();
|
||||
let back: crate::EditorState = serde_json::from_value(j).unwrap();
|
||||
assert_eq!(back.text_edit, s.state.text_edit);
|
||||
let before = s.state.text_edit.clone().expect("editing");
|
||||
let after = back.text_edit.clone().expect("editing");
|
||||
assert_eq!((after.layer, after.anchor, after.caret), (before.layer, before.anchor, before.caret));
|
||||
assert_ne!(after.session, before.session, "a restored editor gets a process-unique replacement epoch");
|
||||
assert_ne!(after.session, 0, "the restored epoch is always a real session token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_session_epoch_tracks_replaced_text_and_layer_restoration() {
|
||||
let (mut s, t) = editing_all("abc");
|
||||
let first = ime_owner(&s);
|
||||
|
||||
// Replacing Source Text is a new document even though the layer id stays.
|
||||
replace_text(&mut s, t, "replaced");
|
||||
let replaced = ime_owner(&s);
|
||||
assert_ne!(replaced, first, "layer.setText starts a new IME epoch");
|
||||
|
||||
// Undo restores another document: it must not revive the token from before either edit.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let undone = ime_owner(&s);
|
||||
assert_ne!(undone, replaced);
|
||||
assert_ne!(undone, first);
|
||||
|
||||
// Clear + immediate undo in the same command sequence: the layer is gone long enough to
|
||||
// end the session, and the restored layer cannot inherit a composition.
|
||||
begin_edit(&mut s, t);
|
||||
s.execute("edit.clear", json!({"layers": [t]})).unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "clearing the edited layer ends the session");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "undo does not recreate the old composition session");
|
||||
begin_edit(&mut s, t);
|
||||
assert_ne!(ime_owner(&s), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_ime_deletion_keeps_a_static_plain_text_value_and_records_no_step() {
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
let (mut s, t) = session("abc");
|
||||
// Legacy/imported text can still be stored as a plain Str rather than a TextDoc.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let pr = std::sync::Arc::make_mut(&mut s.project)
|
||||
.comp_mut(cid)
|
||||
.unwrap()
|
||||
.layer_mut(effectcraft_project::LayerId(t))
|
||||
.unwrap()
|
||||
.props
|
||||
.prop_mut("text/sourceText")
|
||||
.unwrap();
|
||||
pr.value = KV::Str("abc".into());
|
||||
begin_edit_at(&mut s, t, 0);
|
||||
let steps = undo_steps(&s);
|
||||
|
||||
// A non-zero before count at the document start deletes nothing.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap();
|
||||
|
||||
let pr = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().props.prop("text/sourceText").unwrap();
|
||||
assert!(matches!(&pr.value, KV::Str(_)), "a no-op keeps the static Str variant");
|
||||
assert!(pr.keys.is_empty());
|
||||
assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_ime_deletion_does_not_add_a_keyframe_to_animated_source_text() {
|
||||
let (mut s, t) = editing("abc", 0);
|
||||
animate_source(&mut s, t);
|
||||
let lid = effectcraft_project::LayerId(t);
|
||||
let keys = |s: &Session| s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap().keys.len();
|
||||
assert_eq!(keys(&s), 1, "one key from the stopwatch");
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
let steps = undo_steps(&s);
|
||||
|
||||
// A non-zero before count at the document start deletes nothing, at a time with no key.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap();
|
||||
|
||||
assert_eq!(keys(&s), 1, "a no-op does not key Source Text at the CTI");
|
||||
assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_writes_replace_the_edited_document_only_when_the_text_changes() {
|
||||
let (mut s, t) = session("abc");
|
||||
let other = s.execute("layer.newText", json!({"text": "other"})).unwrap()["layer"].as_u64().unwrap();
|
||||
begin_edit_at(&mut s, t, 1);
|
||||
let owner = ime_owner(&s);
|
||||
let steps = undo_steps(&s);
|
||||
|
||||
// Writing the text the document already has is a no-op: same document, same owner, no step.
|
||||
set_source(&mut s, t, "abc");
|
||||
assert_ime_owner(&s, owner, "a no-op write keeps the IME owner");
|
||||
assert_eq!(undo_steps(&s), steps, "a no-op write records no undo step");
|
||||
replace_text(&mut s, t, "abc");
|
||||
assert_ime_owner(&s, owner, "a no-op layer.setText keeps the IME owner");
|
||||
assert_eq!(undo_steps(&s), steps, "a no-op layer.setText records no undo step");
|
||||
|
||||
// Another property of the edited layer leaves its document alone.
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
assert_ime_owner(&s, owner, "an unrelated property keeps the IME owner");
|
||||
|
||||
// Source Text of another layer is not the editor's document either.
|
||||
set_source(&mut s, other, "changed");
|
||||
assert_ime_owner(&s, owner, "another layer's text keeps the IME owner");
|
||||
|
||||
// Replacing the edited layer's Source Text is a document replacement: a new IME epoch.
|
||||
set_source(&mut s, t, "replaced");
|
||||
assert_text(&s, t, "replaced");
|
||||
assert_ime_epoch_changed(&s, owner, "replacing the document changes the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_of_an_unrelated_property_keep_the_ime_owner_and_selection() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
assert_ime_owner(&s, owner, "the opacity write keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "the opacity write keeps the selection");
|
||||
|
||||
// The opacity step does not change Source Text: restoring it keeps the same editor.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_ime_owner(&s, owner, "undoing only opacity keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "undoing only opacity keeps the selection");
|
||||
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_ime_owner(&s, owner, "redoing only opacity keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "redoing only opacity keeps the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_only_text_change_and_its_undo_replace_the_editor_epoch() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// The same characters with another run style are still a different document.
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [1, 4], "size": 12})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcdef");
|
||||
assert_eq!(doc(&s, t).style_at(2).size, 12.0);
|
||||
assert_ime_epoch_changed(&s, owner, "a style-only change starts a new IME epoch");
|
||||
assert_eq!(sel(&s), (3, 3), "a same-length style change keeps the caret in range");
|
||||
let styled = ime_owner(&s);
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).style_at(2).size, 60.0);
|
||||
assert_ime_epoch_changed(&s, styled, "undoing a style change starts a new IME epoch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_jumps_keep_the_owner_over_unrelated_steps_and_replace_it_when_the_text_changes() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 25})).unwrap();
|
||||
let states = s.execute("edit.history.list", json!({})).unwrap()["states"].as_array().unwrap().clone();
|
||||
let cur = states.iter().find(|n| n["current"] == true).unwrap()["index"].as_u64().unwrap();
|
||||
let parent = states.iter().find(|n| n["index"].as_u64() == Some(cur)).unwrap()["parent"].as_u64().unwrap();
|
||||
|
||||
// Both states show the same Source Text: jumping between them keeps the editor.
|
||||
s.execute("edit.history.goto", json!({"index": parent})).unwrap();
|
||||
assert_ime_owner(&s, owner, "jumping to another opacity state keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "and the selection");
|
||||
s.execute("edit.history.goto", json!({"index": cur})).unwrap();
|
||||
assert_ime_owner(&s, owner, "jumping back keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "and the selection");
|
||||
|
||||
// A history state whose Source Text differs is a document replacement.
|
||||
set_source(&mut s, t, "abc");
|
||||
let changed = ime_owner(&s);
|
||||
assert_ne!(changed, owner);
|
||||
s.execute("edit.history.goto", json!({"index": parent})).unwrap();
|
||||
assert_text(&s, t, "abcdef");
|
||||
assert_ime_epoch_changed(&s, changed, "jumping to a different document starts a new epoch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_batch_rollback_keeps_the_owner_for_unrelated_steps_and_replaces_it_for_text_changes() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 50}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_ime_owner(&s, owner, "rolling back an unrelated step keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "and the selection");
|
||||
|
||||
// A partially applied text replacement was still shown before the rollback restores the
|
||||
// original document, so the editor must not revive the composition it had before the batch.
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "layer.setText", "params": {"layer": t, "text": "ab"}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_text(&s, t, "abcdef");
|
||||
assert_ime_epoch_changed(&s, owner, "a rolled-back text change does not revive the old owner");
|
||||
assert_eq!(sel(&s), (3, 3), "the restored document keeps the caret in range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_batch_rollback_after_a_transient_editor_restart_bumps_the_epoch() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// `text.edit` restarts the session even on the layer that is already edited. The rollback
|
||||
// restores the pre-batch TextEdit (with the old token), so the intermediate restart must be
|
||||
// detected from the snapshot: neither the old composition nor its token may be revived.
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "text.edit", "params": {"layer": t, "caret": 1}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(sel(&s), (3, 3), "the restored selection is the pre-batch one");
|
||||
assert_ime_epoch_changed(&s, owner, "a canceled intermediate editor must not revive the old token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_batch_rollback_after_a_cross_comp_editor_switch_bumps_the_epoch() {
|
||||
let (mut s, t) = session("abcdef");
|
||||
let original = s.active_comp_id().unwrap();
|
||||
// Give the other comp a layer with the same id and the same Source Text, so only the comp
|
||||
// and editor-session identity distinguishes the two editors.
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone();
|
||||
let other = new_other_comp(&mut s);
|
||||
let other_id = effectcraft_project::ItemId(other);
|
||||
std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer);
|
||||
s.execute("comp.open", json!({"comp": original.0})).unwrap();
|
||||
begin_edit_at(&mut s, t, 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "comp.open", "params": {"comp": other}},
|
||||
{"command": "text.edit", "params": {"layer": t, "caret": 1}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp");
|
||||
assert_eq!(sel(&s), (3, 3), "and the pre-batch selection");
|
||||
assert_ime_epoch_changed(&s, owner, "an identical layer in another comp must not revive the old token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_atomic_batch_drops_the_events_its_failed_steps_queued() {
|
||||
let (mut s, _) = session("abcdef");
|
||||
let original = s.active_comp_id().unwrap();
|
||||
// `comp.new` opens the comp it creates; go back so the batch's switch is a real one.
|
||||
let other = new_other_comp(&mut s);
|
||||
s.execute("comp.open", json!({"comp": original.0})).unwrap();
|
||||
s.drain_events();
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "comp.open", "params": {"comp": other}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp");
|
||||
assert!(
|
||||
!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(id) if id.0 == other)),
|
||||
"the rolled-back step's OpenComp is not queued for the UI"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_batch_rollback_after_closing_the_editor_does_not_revive_it() {
|
||||
let (mut s, t) = session("abcdef");
|
||||
let original = s.active_comp_id().unwrap();
|
||||
let other = new_other_comp(&mut s);
|
||||
s.execute("comp.open", json!({"comp": original.0})).unwrap();
|
||||
begin_edit_at(&mut s, t, 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// The failing state has no editor at all: the restoration brings the pre-batch one back and
|
||||
// must treat it as unverified rather than revive its composition.
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "comp.open", "params": {"comp": other}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert!(s.state.text_edit.is_some(), "the batch restored the editor state");
|
||||
assert_eq!(sel(&s), (3, 3));
|
||||
assert_ime_epoch_changed(&s, owner, "a canceled editor close must not revive the old token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_without_an_editor_snapshot_replaces_an_open_editor() {
|
||||
let (mut s, _) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// `None` means no editor was open when the state was replaced; an editor that is open
|
||||
// afterwards cannot prove it kept its document.
|
||||
s.sanitize_after_replacement(None);
|
||||
assert_ime_epoch_changed(&s, owner, "an unverified restored editor starts a new epoch");
|
||||
assert_eq!(sel(&s), (3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_to_a_shorter_document_clamps_the_caret() {
|
||||
let (mut s, t) = editing("abc", 3);
|
||||
replace_text(&mut s, t, "abcdef");
|
||||
select_anchor(&mut s, 6, 6);
|
||||
let owner = ime_owner(&s);
|
||||
assert_eq!(sel(&s), (6, 6));
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_text(&s, t, "abc");
|
||||
assert_eq!(sel(&s), (3, 3), "undoing to a shorter document clamps the caret");
|
||||
assert_ime_epoch_changed(&s, owner, "and starts a new epoch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ime_stamp_revision_follows_the_editor_not_unrelated_project_edits() {
|
||||
let (mut s, t) = editing("abcdef", 6);
|
||||
let revision = ime_stamp_revision(&s);
|
||||
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
assert_eq!(ime_stamp_revision(&s), revision, "an unrelated property write does not advance it");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(ime_stamp_revision(&s), revision, "undoing it does not either");
|
||||
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(ime_stamp_revision(&s), revision, "a rolled-back unrelated batch restores it");
|
||||
|
||||
s.execute("text.insert", json!({"text": "x"})).unwrap();
|
||||
assert!(ime_stamp_revision(&s) > revision, "typing advances it");
|
||||
let typed = ime_stamp_revision(&s);
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "y"})).unwrap();
|
||||
assert!(ime_stamp_revision(&s) > typed, "a replaced document advances it too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_atomic_batch_with_a_transient_edit_keeps_its_high_water_ime_revision() {
|
||||
let (mut s, t) = editing("abcdef", 6);
|
||||
let revision = ime_stamp_revision(&s);
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "text.insert", "params": {"text": "x"}},
|
||||
{"command": "text.delete", "params": {}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_text(&s, t, "abcdef");
|
||||
// The intermediate document edit is real even though the final document equals the
|
||||
// pre-batch one: its stamp must remain the high-water mark.
|
||||
assert!(ime_stamp_revision(&s) > revision, "a transient edit must not rewind the IME revision");
|
||||
let after_transient = ime_stamp_revision(&s);
|
||||
|
||||
// An unrelated failed batch really did not edit the document: it must preserve the revision
|
||||
// exactly, or every wide-selection composition would be canceled by unrelated work.
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(ime_stamp_revision(&s), after_transient, "an unrelated rolled-back batch preserves the revision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replacing_property_write_clamps_the_selection_so_the_next_backspace_deletes() {
|
||||
let (mut s, t) = editing("abcdef", 6);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
set_source(&mut s, t, "a");
|
||||
assert_text(&s, t, "a");
|
||||
assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range");
|
||||
assert_ne!(ime_owner(&s), owner);
|
||||
|
||||
// Backspace at the clamped caret removes the character instead of no-opping on 5..6.
|
||||
s.execute("text.delete", json!({})).unwrap();
|
||||
assert_text(&s, t, "");
|
||||
assert_eq!(sel(&s), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_writes_on_source_text_replace_the_edited_document_only_when_the_cti_value_changes() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
// Keying the text the CTI already shows leaves the document equal.
|
||||
animate_source(&mut s, t);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
set_source_cti_key(&mut s, t, 0.0, "abc");
|
||||
assert_ime_owner(&s, owner, "a no-op key write keeps the IME owner");
|
||||
|
||||
// `keys.set` changes the key at the CTI: a document replacement.
|
||||
set_source_cti_key(&mut s, t, 0.0, "replaced");
|
||||
assert_text(&s, t, "replaced");
|
||||
assert_ime_epoch_changed(&s, owner, "a changed keyframe changes the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_cti_keyframe_first_replaces_the_document_then_the_last_key_is_a_noop() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
animate_source(&mut s, t);
|
||||
add_source_key(&mut s, t, 1.0, "later");
|
||||
let owner = ime_owner(&s);
|
||||
let lid = effectcraft_project::LayerId(t);
|
||||
let pr = s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap();
|
||||
let uid = pr.uid;
|
||||
let times: Vec<_> = pr.keys.iter().map(|k| k.time).collect();
|
||||
assert_eq!(times.len(), 2);
|
||||
|
||||
// Deleting the key at the CTI (0s) while the 1s key remains shows the later text: replacing
|
||||
// the document starts a new epoch.
|
||||
s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[0] }];
|
||||
assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1));
|
||||
assert_text(&s, t, "later");
|
||||
let replaced = ime_owner(&s);
|
||||
assert_ne!(replaced, owner, "deleting the CTI key changed the document");
|
||||
|
||||
// Removing the last key turns its value into the static CTI value. That value is what the
|
||||
// CTI already showed, so the document does not change and the new owner stays.
|
||||
s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[1] }];
|
||||
assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1));
|
||||
assert_text(&s, t, "later");
|
||||
assert_ime_owner(&s, replaced, "removing the last key is a document no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_the_cti_to_a_shorter_animated_key_clamps_and_starts_a_new_epoch() {
|
||||
let (mut s, t) = session("abcdef");
|
||||
animate_source(&mut s, t);
|
||||
add_source_key(&mut s, t, 1.0, "a");
|
||||
begin_edit_at(&mut s, t, 6);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// The same time still shows the same document: no new epoch.
|
||||
s.execute("time.set", json!({"time": 0.0})).unwrap();
|
||||
assert_text(&s, t, "abcdef");
|
||||
assert_ime_owner(&s, owner, "an unchanged CTI document keeps the IME owner");
|
||||
|
||||
// The key at 1s holds shorter text: the document changes, the selection is clamped and the
|
||||
// editor gets a new epoch.
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
assert_text(&s, t, "a");
|
||||
assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range");
|
||||
let replaced = ime_owner(&s);
|
||||
assert_ne!(replaced, owner, "a changed CTI document starts a new IME epoch");
|
||||
|
||||
// Backspace at the clamped caret removes the character instead of no-opping on 5..6.
|
||||
s.execute("text.delete", json!({})).unwrap();
|
||||
assert_text(&s, t, "");
|
||||
assert_eq!(sel(&s), (0, 0));
|
||||
assert_ime_owner(&s, replaced, "typing after the replacement keeps its owner");
|
||||
|
||||
// Staying at the same time after the edit is a no-op for the document too.
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
assert_text(&s, t, "");
|
||||
assert_ime_owner(&s, replaced, "a no-op time move keeps the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_related_times_replaces_the_edited_document_when_it_moves_the_active_comp() {
|
||||
let (mut s, t) = session("abcdef");
|
||||
animate_source(&mut s, t);
|
||||
add_source_key(&mut s, t, 1.0, "a");
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let r = s.execute("layer.precompose", json!({"layers": [t], "name": "Inner", "open": true})).unwrap();
|
||||
let inner = effectcraft_project::ItemId(r["comp"].as_u64().unwrap());
|
||||
assert_eq!(s.active_comp_id(), Some(inner));
|
||||
|
||||
begin_edit_at(&mut s, t, 6);
|
||||
let owner = ime_owner(&s);
|
||||
s.sync_related_times(outer, effectcraft_time::Tick::from_seconds_f64(1.0));
|
||||
|
||||
assert_text(&s, t, "a");
|
||||
assert_eq!(sel(&s), (1, 1), "the related time move clamps the selection");
|
||||
assert_ime_epoch_changed(&s, owner, "the related time move changed the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_preset_that_writes_source_text_replaces_the_edited_document() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
let owner = ime_owner(&s);
|
||||
let preset = json!({
|
||||
"effectcraftAnimationPreset": 1,
|
||||
"properties": [{"path": "text/sourceText", "value": {"t": "Text", "v": {"text": "replaced"}}, "keys": []}],
|
||||
});
|
||||
s.execute("anim.applyPreset", json!({"preset": preset, "layers": [t]})).unwrap();
|
||||
assert_text(&s, t, "replaced");
|
||||
assert_ime_epoch_changed(&s, owner, "a preset that replaces source text changes the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_property_write_keeps_the_edited_document_and_its_owner() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
let owner = ime_owner(&s);
|
||||
let steps = undo_steps(&s);
|
||||
|
||||
// null cannot coerce to a Text value: the edit fails before anything is committed.
|
||||
assert!(s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": null})).is_err());
|
||||
assert_text(&s, t, "abc");
|
||||
assert_ime_owner(&s, owner, "a failed write keeps the IME owner");
|
||||
assert_eq!(undo_steps(&s), steps, "a failed write records no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_writes_off_the_cti_keep_the_owner_until_they_change_the_cti_document() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
animate_source(&mut s, t);
|
||||
add_source_key(&mut s, t, 1.0, "later");
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// Writing a key at 1s leaves the CTI (0s) value as it was.
|
||||
set_source_key(&mut s, t, 1.0, "changed");
|
||||
assert_text(&s, t, "abc");
|
||||
assert_ime_owner(&s, owner, "an off-CTI write keeps the IME owner");
|
||||
|
||||
// Writing the CTI value itself replaces the document.
|
||||
set_source(&mut s, t, "cti");
|
||||
assert_text(&s, t, "cti");
|
||||
assert_ime_epoch_changed(&s, owner, "a CTI write changes the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_project_cleanup_keeps_the_ime_owner_but_replacement_changes_it() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// file.removeUnusedFootage calls sanitize_state while the edited document is untouched.
|
||||
s.execute("file.importSolid", json!({"name": "Unused"})).unwrap();
|
||||
let removed = s.execute("file.removeUnusedFootage", json!({})).unwrap();
|
||||
assert_eq!(removed["removed"], json!(1), "the unused solid was actually removed");
|
||||
assert_ime_owner(&s, owner, "unrelated cleanup keeps the IME owner");
|
||||
|
||||
// Replacing the Source Text is a document replacement: a new owner.
|
||||
replace_text(&mut s, t, "replaced");
|
||||
assert_ime_epoch_changed(&s, owner, "replacing the document changes the owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_session_does_not_cross_a_composition_switch() {
|
||||
let (mut s, _) = editing_all("abc");
|
||||
let other = new_other_comp(&mut s);
|
||||
s.execute("comp.open", json!({"comp": other})).unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "the old comp's editor is not reused in another comp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_active_comp_does_not_retarget_its_text_editor() {
|
||||
for replacement in [false, true] {
|
||||
let (mut s, t) = session("original");
|
||||
let original = s.active_comp_id().unwrap();
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone();
|
||||
let other = new_other_comp(&mut s);
|
||||
let other_id = effectcraft_project::ItemId(other);
|
||||
std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer);
|
||||
s.execute("comp.open", json!({"comp": other})).unwrap();
|
||||
s.execute("comp.open", json!({"comp": original.0})).unwrap();
|
||||
begin_edit_at(&mut s, t, 1);
|
||||
assert!(s.state.text_edit.is_some());
|
||||
|
||||
// Reproduce cleanup after a project replacement, bypassing Session::edit's earlier
|
||||
// document guard so this specifically exercises sanitize_state's fallback-comp branch.
|
||||
let edited = crate::commands::text_edit::active_editor_snapshot(&s);
|
||||
std::sync::Arc::make_mut(&mut s.project).items.remove(&original);
|
||||
if replacement {
|
||||
s.sanitize_after_replacement(edited.as_ref());
|
||||
} else {
|
||||
s.sanitize_state();
|
||||
}
|
||||
assert_eq!(s.active_comp_id(), Some(other_id));
|
||||
assert!(s.state.text_edit.is_none(), "the same-numbered layer belongs to a different comp");
|
||||
assert!(s.execute("text.insert", json!({"text": "wrong document"})).is_err());
|
||||
assert_eq!(doc(&s, t).text, "original", "the fallback comp was not edited");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -260,26 +941,32 @@ fn fonts_lists_bundled_and_installed_families() {
|
||||
assert_eq!(crate::font_styles("No Such Family").len(), 5);
|
||||
}
|
||||
|
||||
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
|
||||
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
|
||||
fn pinned_variable_font(family: &str) -> Vec<u8> {
|
||||
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
|
||||
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
|
||||
let mut name = u16s(&[0, 2, 30]);
|
||||
/// A big-endian `u16` array, as sfnt tables store them.
|
||||
fn u16s(v: &[u16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_be_bytes()).collect()
|
||||
}
|
||||
|
||||
/// A `name` table with one Windows/Unicode English record per `(id, text)`.
|
||||
fn name_table(strings: &[(u16, String)]) -> Vec<u8> {
|
||||
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
|
||||
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
|
||||
let mut off = 0;
|
||||
for (id, st) in strings.iter().enumerate() {
|
||||
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
|
||||
for (id, st) in &enc {
|
||||
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
strings.iter().for_each(|st| name.extend(st));
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
|
||||
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
|
||||
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
|
||||
enc.iter().for_each(|(_, st)| name.extend(st));
|
||||
name
|
||||
}
|
||||
|
||||
/// A single-face font with `tables` in order (zero checksums, offsets after the directory).
|
||||
fn sfnt(tables: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]);
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
|
||||
out.extend(tag);
|
||||
for (tag, data) in tables {
|
||||
out.extend(*tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
|
||||
out.extend(((12 + 16 * tables.len() + body.len()) as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
}
|
||||
@@ -287,6 +974,15 @@ fn pinned_variable_font(family: &str) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
|
||||
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
|
||||
fn pinned_variable_font(family: &str) -> Vec<u8> {
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
|
||||
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
|
||||
let name = name_table(&[(1, family.to_owned()), (2, "Regular".to_owned())]);
|
||||
sfnt(&[(b"fvar", fvar), (b"name", name)])
|
||||
}
|
||||
|
||||
/// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}`
|
||||
/// lists only families with variation axes, every listing says which families are variable, and
|
||||
/// the font menu's rows carry the flag the filter uses.
|
||||
@@ -321,7 +1017,6 @@ fn fonts_filter_variable_families() {
|
||||
/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name
|
||||
/// `<family without spaces>-<style without spaces>`.
|
||||
fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
|
||||
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
|
||||
let squash = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).collect::<String>();
|
||||
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, 10]);
|
||||
@@ -334,25 +1029,7 @@ fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
|
||||
fvar.extend(((w * 65536.0) as i32).to_be_bytes());
|
||||
fvar.extend(u16s(&[id + 1]));
|
||||
}
|
||||
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
|
||||
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
|
||||
let mut off = 0;
|
||||
for (id, st) in &enc {
|
||||
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
enc.iter().for_each(|(_, st)| name.extend(st));
|
||||
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
|
||||
out.extend(tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
}
|
||||
out.extend(body);
|
||||
out
|
||||
sfnt(&[(b"fvar", fvar), (b"name", name_table(&strings))])
|
||||
}
|
||||
|
||||
/// #1563: a variable font's named instances are styles of its family in `text.fonts`, and a
|
||||
@@ -392,3 +1069,152 @@ fn set_text_schema_accepts_every_opentype_key_and_requires_none() {
|
||||
s.execute_checked("layer.setText", json!({"layer": t, "discretionaryLigatures": true, "ss02": true, "ss20": true})).unwrap();
|
||||
assert!(doc(&s, t).style_at(0).opentype.stylistic_set(20));
|
||||
}
|
||||
|
||||
/// Incoming IME replacement (stock `DeleteSurrounding` / the fork's `DeleteSurroundingBytes`):
|
||||
/// the characters around the selection go, the selected text stays for the following commit, and
|
||||
/// a byte count that ends inside a UTF-8 character leaves that character.
|
||||
#[test]
|
||||
fn ime_replacement_keeps_the_selection_and_never_splits_a_character() {
|
||||
let (mut s, t) = editing_all("abcdef");
|
||||
select(&mut s, 1, 4);
|
||||
// One character before "bcd" and everything after it: the selection survives for the commit.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 99, "merge": "typing-0"})).unwrap();
|
||||
assert_text(&s, t, "bcd");
|
||||
assert_eq!(sel(&s), (0, 3), "the selection is kept for the commit that follows");
|
||||
s.execute("text.insert", json!({"text": "X", "merge": "typing-0"})).unwrap();
|
||||
assert_text(&s, t, "X");
|
||||
assert_eq!(sel(&s), (1, 1));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcdef", "the surrounding delete and its commit are one undo step");
|
||||
// An empty count is a no-op (deletion-only batches, empty commits), never an undo step.
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
select(&mut s, 0, 1);
|
||||
s.execute("text.deleteSurrounding", json!({"before": 0, "after": 0})).unwrap();
|
||||
assert_text(&s, t, "X");
|
||||
|
||||
// Byte counts end inside a character: it stays whole (never a split UTF-8 sequence).
|
||||
let (mut s, t) = editing_all("A😀B");
|
||||
select(&mut s, 3, 3);
|
||||
s.execute("text.deleteSurrounding", json!({"before": 2, "after": 0, "bytes": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "A😀", "B is whole, the emoji only partly covered, so both are not one range");
|
||||
select(&mut s, 2, 2);
|
||||
s.execute("text.deleteSurrounding", json!({"before": 4, "after": 0, "bytes": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "A", "four bytes cover the whole emoji");
|
||||
|
||||
// A combining mark is one character of two bytes: one byte of it removes nothing.
|
||||
let (mut s, t) = editing_all("e\u{301}");
|
||||
select(&mut s, 2, 2);
|
||||
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0, "bytes": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "e\u{301}", "a partial combining mark stays");
|
||||
s.execute("text.deleteSurrounding", json!({"before": 2, "after": 0, "bytes": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "e", "the whole combining mark is one character");
|
||||
}
|
||||
|
||||
/// The outgoing IME query: logical committed text (the pre-edit never enters the model), UTF-8
|
||||
/// byte offsets in either selection direction, a bounded window, and an explicit unavailable
|
||||
/// answer for a selection that cannot be represented.
|
||||
#[test]
|
||||
fn ime_surrounding_maps_characters_to_bytes_and_preserves_direction() {
|
||||
let (mut s, _) = editing_all("café😀");
|
||||
select_anchor(&mut s, 2, 5);
|
||||
let w = ime_surrounding(&s).expect("an available document");
|
||||
assert_eq!(w.text, "café😀");
|
||||
assert_eq!((w.cursor, w.anchor), (9, 2), "the caret is six characters in, after the emoji; the anchor stays at byte 2");
|
||||
assert_eq!(&w.text[w.cursor..], "");
|
||||
assert_eq!(&w.text[..w.anchor], "ca");
|
||||
|
||||
// Reversed: the caret stays the primary end and the anchor the other one.
|
||||
select_anchor(&mut s, 5, 2);
|
||||
let reversed = ime_surrounding(&s).expect("an available document");
|
||||
assert_eq!((reversed.cursor, reversed.anchor), (2, 9), "cursor > anchor is preserved after the conversion");
|
||||
assert_eq!(&reversed.text[reversed.cursor..reversed.anchor], "fé😀");
|
||||
|
||||
// An available empty document is not the unavailable state.
|
||||
let (s, _) = editing_all("");
|
||||
let empty = ime_surrounding(&s).expect("empty text is available");
|
||||
assert_eq!((empty.text.as_str(), empty.cursor, empty.anchor), ("", 0, 0));
|
||||
|
||||
// No edit session: unavailable, not an empty document.
|
||||
let (s, _) = session("text");
|
||||
assert!(ime_surrounding(&s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_surrounding_is_bounded_and_refuses_a_too_wide_selection() {
|
||||
// A long line: the query copies only a window around the caret, never the document.
|
||||
let text = "a".repeat(20_000);
|
||||
let (mut s, _) = editing(&text, 10_000);
|
||||
let w = ime_surrounding(&s).expect("a collapsed caret fits");
|
||||
assert!(w.text.len() <= MAX_IME_TEXT_BYTES, "the payload is bounded");
|
||||
assert!(w.text.len() > 1000, "the caret keeps a useful amount of context");
|
||||
assert!(w.text.is_char_boundary(w.cursor) && w.text.is_char_boundary(w.anchor));
|
||||
assert_eq!(w.cursor, w.anchor);
|
||||
|
||||
// A selection wider than the protocol limit is unavailable, not a truncated one.
|
||||
select(&mut s, 0, 20_000);
|
||||
assert!(ime_surrounding(&s).is_none(), "a too-wide selection is unavailable");
|
||||
|
||||
// Multi-byte text at the window boundary is never split, and offsets stay on boundaries.
|
||||
let text = "é".repeat(5_000);
|
||||
let (s, _) = editing(&text, 2_500);
|
||||
let w = ime_surrounding(&s).expect("a collapsed caret fits");
|
||||
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
|
||||
assert!(w.text.is_char_boundary(w.cursor));
|
||||
assert_eq!(&w.text[w.cursor..w.cursor + "é".len()], "é");
|
||||
}
|
||||
|
||||
/// A reversed selection survives a byte-count deletion: both endpoints shift by the removed
|
||||
/// characters before the selection, its direction stays reversed, and partial characters on
|
||||
/// either side stay whole.
|
||||
#[test]
|
||||
fn ime_byte_deletion_keeps_a_reversed_selection_direction() {
|
||||
let (mut s, t) = editing_all("😀ab😀");
|
||||
select_anchor(&mut s, 3, 1);
|
||||
let (anchor, caret) = sel(&s);
|
||||
assert_eq!((anchor, caret), (3, 1), "reversed before the replacement");
|
||||
|
||||
// Three bytes cover only part of either four-byte emoji: neither is split or removed.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 3, "after": 3, "bytes": true})).unwrap();
|
||||
assert_text(&s, t, "😀ab😀");
|
||||
|
||||
// Four bytes cover each whole emoji: the selection text stays, shifted, still reversed.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 4, "after": 4, "bytes": true})).unwrap();
|
||||
assert_text(&s, t, "ab");
|
||||
assert_eq!(sel(&s), (2, 0), "the anchor stays after the caret");
|
||||
let w = ime_surrounding(&s).expect("available");
|
||||
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("ab", 0, 2), "the outgoing offsets keep the direction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_all_ends_the_open_edit() {
|
||||
let (mut s, t) = editing("Hello", 5);
|
||||
s.execute("text.insert", json!({"text": "!"})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "Hello!");
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "Deselect All ends the open text edit on every route");
|
||||
assert!(s.state.selected_layers.is_empty(), "and leaves nothing selected");
|
||||
assert_eq!(doc(&s, t).text, "Hello!", "the text stays committed");
|
||||
// Ending an edit is no undoable step of its own (as with text.endEdit): undo takes
|
||||
// the typed text, it does not reopen the edit.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "Hello", "undo reverts the typed text, not the deselection");
|
||||
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_all_discards_an_empty_type_tool_layer() {
|
||||
let (mut s, _keep) = session("Hello");
|
||||
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(
|
||||
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
|
||||
"the Type tool opens the edit on its new, empty layer"
|
||||
);
|
||||
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
|
||||
let before = count(&s);
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none());
|
||||
assert!(s.state.selected_layers.is_empty());
|
||||
assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
|
||||
}
|
||||
@@ -613,6 +613,51 @@ fn time_set_reads_frames_style_digits_as_a_frame_count() {
|
||||
assert_eq!(r["display"], "00100");
|
||||
}
|
||||
|
||||
/// Project Settings ▸ Time Display Style ▸ Frames ▸ Frame Count: Start at 0 / Start at 1
|
||||
/// (`app.project.framesCountType`), undoable and saved with the project (#1807).
|
||||
#[test]
|
||||
fn project_settings_frame_count_starts_at_one() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Count", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap();
|
||||
assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012");
|
||||
|
||||
s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap();
|
||||
assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00013");
|
||||
for bad in [json!(2), json!(-1), json!("one")] {
|
||||
assert!(s.execute("file.projectSettings", json!({"frameStart": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.project.settings.frame_start, 1);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ec-frame-start-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("count.ecproj").to_string_lossy().to_string();
|
||||
s.execute("file.save", json!({"path": path})).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
assert_eq!(reopened.project.settings.frame_start, 1);
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012");
|
||||
}
|
||||
|
||||
/// A Duration is a count of frames: Start at 1 and the comp's Start Timecode number positions,
|
||||
/// not lengths (#1807).
|
||||
#[test]
|
||||
fn durations_ignore_the_frame_count_and_the_start_timecode() {
|
||||
use crate::commands::time::display_duration;
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Len", "width": 64, "height": 64, "frameRate": 30, "duration": 10, "startTimecode": "0:00:01:00"})).unwrap();
|
||||
s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap();
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
let len = comp.frame_rate.tick_of(30);
|
||||
for (style, want) in [("frames", "00030"), ("feet35", "0001+14"), ("timecode", "0:00:01:00")] {
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": style})).unwrap();
|
||||
assert_eq!(display_duration(&s, &comp, len), want, "{style}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_dimensions_split_and_rejoin() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! File ▸ Import / Export ▸ Adobe Premiere Pro Project… (timeline interchange) through the command
|
||||
//! registry, without media decoding (pixels: `crates/host/tests/timeline_interop.rs`).
|
||||
//! registry, without media decoding (pixels: `crates/host/tests/it/timeline_interop.rs`).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod key_time_post_snap_bounds;
|
||||
mod key_time_reply_contract;
|
||||
mod parenting_3d_position;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,14 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod audio_sample_positions;
|
||||
mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mov_audio_sample_positions;
|
||||
mod ms_rounded_footage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_audio_sample_positions;
|
||||
mod opus_roundtrip;
|
||||
mod render_settings;
|
||||
mod roundtrip;
|
||||
mod uncompressed_mov;
|
||||
mod vp9_roundtrip;
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
//! Tiny native WAV to ProRes PCM MOV sample-position proof.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
File renamed without changes.
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
//! Native MediaPool decoding and native WAV export preserve selected sample positions.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -461,7 +461,7 @@ Comp.prototype = {
|
||||
var l = this.layer(i);
|
||||
if (l.__info[10] === 'Camera' && l.active) return l;
|
||||
}
|
||||
__err('Composition "' + this.name + '" has no active camera');
|
||||
return __err('Composition "' + this.name + '" has no active camera');
|
||||
},
|
||||
layer: function (a, rel) {
|
||||
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
|
||||
|
||||
@@ -528,8 +528,9 @@ fn rewrite_seq(items: &[Item]) -> String {
|
||||
let end = assignment_end(items, j + 1);
|
||||
let rhs = rewrite_seq(&items[j + 1..end]);
|
||||
let h = helper(&op.text[..1]).unwrap_or("__add");
|
||||
let target = compound_assignment_target(items, i, j);
|
||||
out.push_str(&p.ws);
|
||||
out.push_str(&format!("{} ={}{h}({}, ({rhs}))", p.body, op.ws, p.body));
|
||||
out.push_str(&format!("{target} ={}{h}({target}, ({rhs}))", op.ws));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
@@ -543,6 +544,34 @@ fn rewrite_seq(items: &[Item]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn raw_ref(item: &Item) -> String {
|
||||
match item {
|
||||
Item::Tok(t) => format!("{}{}", t.ws, t.text),
|
||||
Item::Group { open, inner, close } => {
|
||||
let mut out = format!("{}{}", open.ws, open.text);
|
||||
if has_assign(inner) {
|
||||
// shortcut: nested assignments stay verbatim (rewriting them would double per level), so arithmetic beside them is not array-aware
|
||||
for item in inner {
|
||||
out.push_str(&raw_ref(item));
|
||||
}
|
||||
} else {
|
||||
out.push_str(&rewrite_seq(inner));
|
||||
}
|
||||
if let Some(close) = close {
|
||||
out.push_str(&close.text);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_assign(items: &[Item]) -> bool {
|
||||
items.iter().any(|it| match it {
|
||||
Item::Tok(t) => ASSIGN.contains(&t.text.as_str()),
|
||||
Item::Group { inner, .. } => has_assign(inner),
|
||||
})
|
||||
}
|
||||
|
||||
fn push_piece(out: &mut String, p: Piece) {
|
||||
out.push_str(&p.ws);
|
||||
out.push_str(&p.body);
|
||||
@@ -554,6 +583,18 @@ fn push_piece(out: &mut String, p: Piece) {
|
||||
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
|
||||
/// nest without brackets count as part of a level ([`too_nested`]).
|
||||
pub const MAX_NESTING: usize = 32;
|
||||
/// Hard limit on bytes emitted by the array-math source rewrite.
|
||||
pub const MAX_REWRITTEN_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
fn over_rewrite_limit(text: &str) -> bool {
|
||||
text.len() > MAX_REWRITTEN_BYTES
|
||||
}
|
||||
|
||||
fn compound_assignment_target(items: &[Item], start: usize, end: usize) -> String {
|
||||
// Rewriting the whole left side duplicates nested computed assignment targets. Keep the
|
||||
// original target syntax for both reads/writes; source length is bounded above.
|
||||
items[start..end].iter().map(raw_ref).collect()
|
||||
}
|
||||
|
||||
/// Stack cost of one level of brackets (also of a template substitution, a regex group and a
|
||||
/// prefix operator, which cost the parser about as much or are never chained in real code).
|
||||
@@ -746,12 +787,18 @@ fn too_nested_from(toks: &[Tok], base: usize) -> bool {
|
||||
|
||||
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
|
||||
pub fn check_nesting(src: &str) -> Result<(), String> {
|
||||
if over_rewrite_limit(src) {
|
||||
return Err(too_large());
|
||||
}
|
||||
let (toks, tail) = tokenize(src);
|
||||
if too_nested(&toks) {
|
||||
return Err(too_deep());
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if over_rewrite_limit(&out) {
|
||||
return Err(too_large());
|
||||
}
|
||||
if too_nested(&tokenize(&out).0) {
|
||||
return Err(too_deep());
|
||||
}
|
||||
@@ -765,12 +812,18 @@ fn too_deep() -> String {
|
||||
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
|
||||
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
|
||||
pub fn rewrite(src: &str) -> String {
|
||||
if over_rewrite_limit(src) {
|
||||
return throw_too_large();
|
||||
}
|
||||
let (toks, tail) = tokenize(src);
|
||||
if too_nested(&toks) {
|
||||
return throw_too_deep();
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if over_rewrite_limit(&out) {
|
||||
return throw_too_large();
|
||||
}
|
||||
if too_nested(&tokenize(&out).0) {
|
||||
return throw_too_deep();
|
||||
}
|
||||
@@ -781,6 +834,14 @@ fn throw_too_deep() -> String {
|
||||
format!("throw new RangeError({:?})", too_deep().trim_start_matches("Error: "))
|
||||
}
|
||||
|
||||
fn too_large() -> String {
|
||||
format!("Error: expression exceeds the {MAX_REWRITTEN_BYTES}-byte rewrite limit")
|
||||
}
|
||||
|
||||
fn throw_too_large() -> String {
|
||||
format!("throw new RangeError({:?})", too_large().trim_start_matches("Error: "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_NESTING, check_nesting, rewrite};
|
||||
@@ -821,6 +882,8 @@ mod tests {
|
||||
#[test]
|
||||
fn compound_assignment_and_functions() {
|
||||
assert_eq!(n(&rewrite("x += [1, 0];")), n("x = __add(x, ( [1, 0]));"));
|
||||
assert_eq!(n(&rewrite("obj[key] += 1")), n("obj[key] = __add(obj[key], (1))"));
|
||||
assert_eq!(n(&rewrite("o[a + b] += 1")), n("o[__add(a, b)] = __add(o[__add(a, b)], (1))"));
|
||||
assert_eq!(n(&rewrite("function f(a) { return a * 2 }")), n("function f(a) { return __mul(a, 2) }"));
|
||||
assert_eq!(n(&rewrite("var g = (a) => a - 1")), n("var g = (a) => __sub(a, 1)"));
|
||||
assert_eq!(n(&rewrite("typeof a + 'x'")), n("__add(typeof a, 'x')"));
|
||||
@@ -828,6 +891,20 @@ mod tests {
|
||||
assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_expansion_is_bounded_before_rewriting() {
|
||||
let src = "x += 1;".repeat(super::MAX_REWRITTEN_BYTES / 6 + 1);
|
||||
assert!(check_nesting(&src).unwrap_err().contains("rewrite limit"));
|
||||
assert!(rewrite(&src).starts_with("throw new RangeError("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_expansion_is_bounded() {
|
||||
let src = format!("{} += 1", "a".repeat(super::MAX_REWRITTEN_BYTES / 2));
|
||||
assert!(check_nesting(&src).unwrap_err().contains("rewrite limit"));
|
||||
assert!(rewrite(&src).starts_with("throw new RangeError("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_an_error_not_a_stack_overflow() {
|
||||
// Each level costs the JavaScript parser stack: ~60 nested brackets overflowed a 2 MiB
|
||||
|
||||
@@ -99,8 +99,8 @@ pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2],
|
||||
};
|
||||
let mut acc = [0.0f64; 4];
|
||||
let mut n = 0.0;
|
||||
for y in y0..=y1.min(y0 + 4096) {
|
||||
for x in x0..=x1.min(x0 + 4096) {
|
||||
for y in y0..=y1.min(y0.saturating_add(4096)) {
|
||||
for x in x0..=x1.min(x0.saturating_add(4096)) {
|
||||
let p = b.img.get(x, y);
|
||||
for k in 0..4 {
|
||||
acc[k] += p[k] as f64;
|
||||
|
||||
@@ -1166,6 +1166,13 @@ fn sample_image_reads_rendered_pixels() {
|
||||
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
|
||||
}
|
||||
|
||||
/// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds.
|
||||
#[test]
|
||||
fn sample_image_huge_point_is_transparent() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("var c = thisComp.layer(\"B\").sampleImage([1e300, 1e300]); c[3]", 0.0), 0.0);
|
||||
}
|
||||
|
||||
fn data_item(p: &mut Project, name: &str, text: &str) {
|
||||
let f = effectcraft_project::Footage {
|
||||
path: format!("/data/{name}"),
|
||||
|
||||
@@ -360,6 +360,9 @@ impl Mat4 {
|
||||
}
|
||||
/// General inverse (Gauss-Jordan with partial pivoting).
|
||||
pub fn inverse(&self) -> Option<Mat4> {
|
||||
if !self.0.iter().flatten().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut a = self.0;
|
||||
let mut inv = Mat4::IDENTITY.0;
|
||||
for col in 0..4 {
|
||||
@@ -450,6 +453,16 @@ mod tests {
|
||||
(a - b).length() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mat4_inverse_rejects_non_finite() {
|
||||
for (r, c, v) in [(0, 0, f64::NAN), (2, 1, f64::NAN), (1, 3, f64::INFINITY)] {
|
||||
let mut m = Mat4::IDENTITY;
|
||||
m.0[r][c] = v;
|
||||
assert!(m.inverse().is_none(), "{r},{c},{v}");
|
||||
}
|
||||
assert!(Mat4::IDENTITY.inverse().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_2d_maps_anchor_to_position() {
|
||||
let m = Mat3::layer_2d(vec2(50.0, 25.0), vec2(960.0, 540.0), vec2(200.0, 50.0), 30.0);
|
||||
|
||||
@@ -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}"),
|
||||
})?;
|
||||
@@ -1246,6 +1249,24 @@ impl<'g> Enc<'g> {
|
||||
);
|
||||
}
|
||||
|
||||
/// A `w` × `h` image showing `src` with its pixel (`x`, `y`) at the top-left, transparent
|
||||
/// where `src` doesn't reach (Image::crop).
|
||||
pub fn crop(&mut self, src: &GpuImage, x: i64, y: i64, w: u32, h: u32) -> GpuImage {
|
||||
let dst = self.image(w, h);
|
||||
let span = |o: i64, n: u32, len: u32| {
|
||||
let (a, b) = (o.max(0), (o + n as i64).min(len as i64));
|
||||
(b > a).then(|| (a as u32, (a - o) as u32, (b - a) as u32))
|
||||
};
|
||||
let (Some((sx, dx, cw)), Some((sy, dy, ch))) = (span(x, w, src.width), span(y, h, src.height)) else { return dst };
|
||||
let enc = self.encoder();
|
||||
enc.copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d { x: sx, y: sy, z: 0 }, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x: dx, y: dy, z: 0 }, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::Extent3d { width: cw, height: ch, depth_or_array_layers: 1 },
|
||||
);
|
||||
dst
|
||||
}
|
||||
|
||||
/// Submit recorded work.
|
||||
pub fn submit(&mut self) {
|
||||
if let Some((enc, id)) = self.enc.take() {
|
||||
|
||||
@@ -252,6 +252,7 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> u12
|
||||
k.u64(e.effect_index as u64);
|
||||
k.debug(&e.working_space);
|
||||
k.u64(e.working_linear as u64);
|
||||
k.u64(e.float_depth as u64);
|
||||
k.debug(&e.shutter);
|
||||
}
|
||||
k.finish()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -388,7 +397,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
// ---------------------------------------------------------------- per-pixel
|
||||
|
||||
fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse(ctx.params.s(id)));
|
||||
let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse_natural(ctx.params.s(id)));
|
||||
if parsed.iter().all(Option::is_none) {
|
||||
return Some(b);
|
||||
}
|
||||
|
||||
@@ -90,11 +90,10 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
"ec.color.blackwhite" => {
|
||||
let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0);
|
||||
let tc = pr.color("tintColor");
|
||||
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
|
||||
p.u[0] = [3, pr.b("tint") as u32, 0, 0];
|
||||
p.f[0] = [w[0], w[1], w[2], w[3]];
|
||||
p.f[1] = [w[4], w[5], 0.0, 0.0];
|
||||
p.f[2] = [th, ts, 0.0, 0.0];
|
||||
p.f[2] = [tc[0], tc[1], tc[2], 0.0];
|
||||
}
|
||||
"ec.color.tritone" => {
|
||||
p.u[0][0] = 4;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -353,10 +356,10 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0];
|
||||
p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1));
|
||||
}
|
||||
let blend = ctx.params.f("blend").max(1.0);
|
||||
let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale);
|
||||
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0];
|
||||
p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
|
||||
p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
|
||||
run(e, "gen_fourcolor", &p, b, None, None)
|
||||
}
|
||||
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -637,13 +637,7 @@ fn motion_tile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let mirror = ctx.params.b("mirrorEdges");
|
||||
let phase = ctx.params.f("phase") / 360.0;
|
||||
let hshift = ctx.params.b("horizontalPhaseShift");
|
||||
let (nw, nh, noff) = if ctx.adjustment {
|
||||
(b.img.width, b.img.height, b.offset)
|
||||
} else {
|
||||
let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s);
|
||||
let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s);
|
||||
((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s])
|
||||
};
|
||||
let (nw, nh, noff) = effectcraft_effects::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s);
|
||||
if !e.g.fits(nw, nh) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -461,3 +461,5 @@ mod tests_fx_warp;
|
||||
mod tests_init;
|
||||
#[cfg(test)]
|
||||
mod tests_particles;
|
||||
#[cfg(test)]
|
||||
mod tests_wgsl_grammar;
|
||||
@@ -179,7 +179,41 @@ fn equirect_uv(d: vec3<f32>, rot: f32) -> vec2<f32> {
|
||||
return vec2<f32>(u - floor(u), v);
|
||||
}
|
||||
|
||||
fn shadow_factor(m: u32, p: vec3<f32>, n_l: f32) -> f32 {
|
||||
fn shadow_texel_at(offset: u32, sz: i32, x: i32, y: i32) -> f32 {
|
||||
if (x < 0 || y < 0 || x >= sz || y >= sz) {
|
||||
return 1e30; // outside the map: the light was never blocked there (finite stand-in for the CPU path's infinity sentinel)
|
||||
}
|
||||
return shadow_texels[offset + u32(y * sz + x)];
|
||||
}
|
||||
|
||||
fn tap_hash(x: i32, y: i32) -> u32 {
|
||||
var h: u32 = (u32(x) * 0x9E3779B9u) ^ (u32(y) * 0x85EBCA6Bu);
|
||||
h = h ^ (h >> 16u);
|
||||
h = h * 0x2545F491u;
|
||||
h = h ^ (h >> 15u);
|
||||
return h;
|
||||
}
|
||||
|
||||
const PCF_MAX_TAPS: i32 = 10;
|
||||
const PLANE_SLOPE_MAX: f32 = 64.0;
|
||||
|
||||
// shade::shadow_factor's `open`: 1 where texel (x, y) does not occlude the receiver
|
||||
// plane (depth slope -t in texel footprints per texel) at the texel's centre; the
|
||||
// perspective compare runs on inverse depth (iz + g·iq), which is linear across the map.
|
||||
fn shadow_open(offset: u32, sz: i32, x: i32, y: i32, ortho: bool, z: f32, texel: f32, bias: f32, t: vec2<f32>, u: f32, v: f32, iz: f32, iq: f32) -> f32 {
|
||||
let s = shadow_texel_at(offset, sz, x, y) + bias;
|
||||
let g = t.x * (f32(x) + 0.5 - u) + t.y * (f32(y) + 0.5 - v);
|
||||
var lit: bool;
|
||||
if (ortho) {
|
||||
lit = z - g * texel <= s;
|
||||
} else {
|
||||
let inv = iz + g * iq;
|
||||
lit = inv <= 0.0 || inv * s >= 1.0;
|
||||
}
|
||||
return select(0.0, 1.0, lit);
|
||||
}
|
||||
|
||||
fn shadow_factor(m: u32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 {
|
||||
let r0 = shadows[m];
|
||||
let r1 = shadows[m + 1u];
|
||||
let r2 = shadows[m + 2u];
|
||||
@@ -205,28 +239,66 @@ fn shadow_factor(m: u32, p: vec3<f32>, n_l: f32) -> f32 {
|
||||
}
|
||||
let z = q.z;
|
||||
let texel = select(abs(z) / focal, 1.0 / focal, ortho);
|
||||
let bias = extra.x * texel * (1.0 + extra.y) * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0)));
|
||||
let step = extra.y / 2.0;
|
||||
// CPU mirror (shade::shadow_factor), step for step: slope-scaled bias at texel
|
||||
// scale (independent of the PCF radius extra.y; a diffusion-scaled bias lifts
|
||||
// shadows off their casters), each texel compared against the receiver plane's
|
||||
// depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid
|
||||
// jittered by an integer hash (bit-identical across backends) of the sub-texel
|
||||
// cell and tap index, and bilinear compares of the four nearest texel centres.
|
||||
// extra.y is the wanted penumbra in output pixels; dividing by the receiver's
|
||||
// texel footprint keeps it in output pixels at any map resolution, and the reach
|
||||
// is at least two texels so hard edges ramp over texel steps instead of showing them.
|
||||
let bias = extra.x * texel * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0)));
|
||||
let sz = i32(size);
|
||||
let nv = vec3<f32>(dot(r0.xyz, ng), dot(r1.xyz, ng), dot(r2.xyz, ng));
|
||||
var den0 = nv.z;
|
||||
if (!ortho) {
|
||||
den0 = nv.x * ((u - c) / focal) + nv.y * ((v - c) / focal) + nv.z;
|
||||
}
|
||||
var t = vec2<f32>(0.0, 0.0);
|
||||
if (den0 < -1e-6) {
|
||||
t = vec2<f32>(clamp(nv.x / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), clamp(nv.y / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX));
|
||||
}
|
||||
let iz = 1.0 / z;
|
||||
let iq = 1.0 / (focal * z);
|
||||
var reach = clamp(extra.y / texel, 2.0, 256.0);
|
||||
if (!(reach < 1e30)) {
|
||||
reach = 1.0;
|
||||
}
|
||||
let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS);
|
||||
let step = reach / f32(n - 1);
|
||||
let half = f32(n - 1) * 0.5;
|
||||
let cx = i32(floor(u * 8.0));
|
||||
let cy = i32(floor(v * 8.0));
|
||||
var lit = 0.0;
|
||||
for (var j = -2; j <= 2; j = j + 1) {
|
||||
for (var i = -2; i <= 2; i = i + 1) {
|
||||
let x = i32(floor(u + f32(i) * step));
|
||||
let y = i32(floor(v + f32(j) * step));
|
||||
if (x < 0 || y < 0 || x >= sz || y >= sz) {
|
||||
lit = lit + 1.0;
|
||||
continue;
|
||||
}
|
||||
let d = shadow_texels[offset + u32(y * sz + x)];
|
||||
if (z - bias <= d) {
|
||||
lit = lit + 1.0;
|
||||
}
|
||||
for (var j = 0; j < n; j = j + 1) {
|
||||
for (var i = 0; i < n; i = i + 1) {
|
||||
let h = tap_hash(cx + i * 7919, cy + j * 7919);
|
||||
let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;
|
||||
let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5;
|
||||
let su = u + ((f32(i) - half) + jx) * step;
|
||||
let sv = v + ((f32(j) - half) + jy) * step;
|
||||
// Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the
|
||||
// four is compared against the receiver plane at that centre.
|
||||
let tu = su - 0.5;
|
||||
let tv = sv - 0.5;
|
||||
let fx = tu - floor(tu);
|
||||
let fy = tv - floor(tv);
|
||||
let x0 = i32(floor(tu));
|
||||
let y0 = i32(floor(tv));
|
||||
let h00 = shadow_open(offset, sz, x0, y0, ortho, z, texel, bias, t, u, v, iz, iq);
|
||||
let h10 = shadow_open(offset, sz, x0 + 1, y0, ortho, z, texel, bias, t, u, v, iz, iq);
|
||||
let h01 = shadow_open(offset, sz, x0, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq);
|
||||
let h11 = shadow_open(offset, sz, x0 + 1, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq);
|
||||
let a = h00 + (h10 - h00) * fx;
|
||||
let b = h01 + (h11 - h01) * fx;
|
||||
lit = lit + a + (b - a) * fy;
|
||||
}
|
||||
}
|
||||
return lit / 25.0;
|
||||
return lit / f32(n * n);
|
||||
}
|
||||
|
||||
fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32, p: vec3<f32>, n_l: f32) -> f32 {
|
||||
fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 {
|
||||
if (shadow < 0) {
|
||||
return 1.0;
|
||||
}
|
||||
@@ -242,7 +314,7 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32,
|
||||
face = select(5u, 4u, d.z > 0.0);
|
||||
}
|
||||
}
|
||||
let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, n_l);
|
||||
let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, ng, n_l);
|
||||
return 1.0 - darkness * (1.0 - f);
|
||||
}
|
||||
|
||||
@@ -330,7 +402,7 @@ fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32
|
||||
if (n_l <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n_l));
|
||||
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n, n_l));
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
@@ -399,6 +471,8 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
if (!front) {
|
||||
n = -n;
|
||||
}
|
||||
// Shadow lookups follow the geometric surface, not the normal map.
|
||||
let ng = n;
|
||||
let tex_n = i32(m3.z);
|
||||
if (tex_n >= 0) {
|
||||
let t = sample_tex(tex_n, i.uv);
|
||||
@@ -446,7 +520,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
}
|
||||
var sh = 1.0;
|
||||
if (receives_shadows) {
|
||||
sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, n_l);
|
||||
sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, ng, n_l);
|
||||
}
|
||||
let b = brdf(rgb, metallic, rough, spec_k, diff_k, n, v, to_l);
|
||||
acc = acc + b * l2.xyz * k * sh;
|
||||
|
||||
Loaded 100 of 427 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user