mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
|
||||
<p align="center">
|
||||
A free, open-source compositor: compositions, layers,
|
||||
keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
|
||||
keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
|
||||
Windows and Linux, and in the browser. Young, moving fast, and already usable.
|
||||
</p>
|
||||
|
||||
@@ -127,7 +127,7 @@ or into the Timeline, between layers and at the time you point to.
|
||||
|
||||
## Effects
|
||||
|
||||
306 built-in effects across its categories, each with its parameter names, order and
|
||||
308 built-in effects across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
|
||||
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
|
||||
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
|
||||
@@ -231,7 +231,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
||||
## Where it stands
|
||||
|
||||
EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set
|
||||
exists, including 306 effects, but breadth is not the same as full maturity across every edge
|
||||
exists, including 308 effects, but breadth is not the same as full maturity across every edge
|
||||
case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended
|
||||
for production client work. In particular:
|
||||
|
||||
|
||||
@@ -182,6 +182,20 @@ mod tests {
|
||||
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
|
||||
}
|
||||
|
||||
/// The README quotes the number of effects (#2565): every count it gives is the registry's.
|
||||
#[test]
|
||||
fn readme_effect_counts_are_current() {
|
||||
let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default();
|
||||
let words: Vec<&str> = readme.split_whitespace().collect();
|
||||
let counts: Vec<&str> = words
|
||||
.windows(3)
|
||||
.filter(|w| w[0].parse::<u32>().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects"))))
|
||||
.map(|w| w[0])
|
||||
.collect();
|
||||
let n = super::registry().len().to_string();
|
||||
assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_only_controls_keep_effects_off_the_gpu() {
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
@@ -95,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let size = (ctx.params.f("size") * b.scale).max(1.0);
|
||||
if ctx.params.b("resizeLayer") && !ctx.adjustment {
|
||||
b.pad(amount.abs().ceil() as u32 + 1);
|
||||
} else {
|
||||
// Room for pixels displaced past the content, up to the layer's edge (#1205).
|
||||
b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1));
|
||||
}
|
||||
let off = b.to_px(ctx.params.v2("offset"));
|
||||
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
|
||||
|
||||
+104
-2
@@ -312,9 +312,23 @@ impl Buf {
|
||||
self.offset[0] += l as f64;
|
||||
self.offset[1] += t as f64;
|
||||
}
|
||||
/// Grow by `pad` transparent pixels on every side (as clamped by [`Image::effective_pad`]).
|
||||
/// Grow by `left` and `top` transparent pixels on those sides only.
|
||||
pub fn grow_left_top(&mut self, left: u32, top: u32) {
|
||||
self.pad_sides([left, top, 0, 0]);
|
||||
}
|
||||
/// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of
|
||||
/// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled
|
||||
/// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at
|
||||
/// its content's corner instead of the cover's resamples to different pixels (#1205).
|
||||
pub fn align(&mut self, size: [f64; 2]) {
|
||||
if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) {
|
||||
self.grow_left_top(l, t);
|
||||
}
|
||||
}
|
||||
/// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as
|
||||
/// clamped by [`Image::effective_pad`].
|
||||
pub fn pad(&mut self, pad: u32) {
|
||||
let eff = self.img.effective_pad(pad);
|
||||
let eff = self.img.effective_pad(pad_within_budget(self.img.width, self.img.height, pad));
|
||||
if eff == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -324,6 +338,21 @@ impl Buf {
|
||||
}
|
||||
}
|
||||
|
||||
/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within
|
||||
/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer
|
||||
/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path.
|
||||
pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 {
|
||||
const MIN_BUDGET: f64 = 16_777_216.0;
|
||||
let (w, h) = (f64::from(w), f64::from(h));
|
||||
let budget = MIN_BUDGET.max(4.0 * w * h);
|
||||
if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget {
|
||||
return pad;
|
||||
}
|
||||
// (w + 2p)(h + 2p) = budget, solved for p.
|
||||
let p = (((w - h).powi(2) + 4.0 * budget).sqrt() - (w + h)) / 4.0;
|
||||
(p.floor().max(0.0) as u32).min(pad)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -361,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3
|
||||
Some((dx as i64, dy as i64))
|
||||
}
|
||||
|
||||
/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge
|
||||
/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these
|
||||
/// skips the renderer's cover of the whole layer rectangle (#1205).
|
||||
pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"];
|
||||
|
||||
/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels
|
||||
/// of the resampling pyramid used for downscaled layers.
|
||||
pub const GRID_ALIGN: u32 = 64;
|
||||
|
||||
/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None`
|
||||
/// when there is nothing to add.
|
||||
pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> {
|
||||
let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?;
|
||||
let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN);
|
||||
(l != 0 || t != 0).then_some((l, t))
|
||||
}
|
||||
|
||||
/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment
|
||||
/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so
|
||||
/// the grid phase stays. 0 once the buffer covers the layer.
|
||||
pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 {
|
||||
if ctx.adjustment {
|
||||
return 0;
|
||||
}
|
||||
want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -539,6 +595,15 @@ pub struct EffectSpec {
|
||||
pub float: bool,
|
||||
}
|
||||
|
||||
impl EffectSpec {
|
||||
/// True when this effect grows its own buffer (blur/glow margins, Repeat Edge
|
||||
/// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the
|
||||
/// renderer's full-layer cover and keep the pyramid grid phase (#1205).
|
||||
pub fn skips_cover(&self) -> bool {
|
||||
SKIPS_COVER.contains(&self.id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec {
|
||||
ParamSpec { id, name, default, ui }
|
||||
}
|
||||
@@ -1345,6 +1410,23 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx;
|
||||
/// big layers may still grow to four times their area, and small pads are untouched.
|
||||
#[test]
|
||||
fn padded_buffers_stay_within_the_budget() {
|
||||
let area = |w: u32, h: u32, p: u32| (u64::from(w) + 2 * u64::from(p)) * (u64::from(h) + 2 * u64::from(p));
|
||||
let far = radial_fast_pad((6500.0, 6500.0), 20, 20, 0.8);
|
||||
assert_eq!(far, 4096, "the effect asks for its full pad");
|
||||
let mut b = Buf { img: Image::new(20, 20), offset: [0.0; 2], scale: 1.0 };
|
||||
b.pad(far);
|
||||
assert!(area(20, 20, 0) < area(b.img.width, b.img.height, 0) && area(b.img.width, b.img.height, 0) <= 1 << 24, "{}x{}", b.img.width, b.img.height);
|
||||
assert_eq!(b.offset, [f64::from((b.img.width - 20) / 2); 2], "the content keeps its place");
|
||||
let p = pad_within_budget(3840, 2160, 4096);
|
||||
assert!(p > 1000 && area(3840, 2160, p) <= 4 * 3840 * 2160 && area(3840, 2160, p + 1) > 4 * 3840 * 2160);
|
||||
assert_eq!(pad_within_budget(640, 360, 200), 200);
|
||||
assert_eq!(pad_within_budget(20, 20, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_fx_passthrough() {
|
||||
let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]);
|
||||
@@ -1550,4 +1632,24 @@ mod tests {
|
||||
assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0)));
|
||||
assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_plan_is_cover_shift_mod_grid() {
|
||||
// Content inset from the layer origin: pixel 0 maps to layer (50, 30).
|
||||
let offset = [-50.0, -30.0];
|
||||
let scale = 1.0;
|
||||
let size = [1920.0, 1080.0];
|
||||
let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap();
|
||||
assert_eq!((dx, dy), (50, 30));
|
||||
assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30)));
|
||||
assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_margin_effects_skip_cover_and_twirl_does_not() {
|
||||
for id in SKIPS_COVER {
|
||||
assert!(find(id).expect(id).skips_cover(), "{id}");
|
||||
}
|
||||
assert!(!find("ec.distort.twirl").unwrap().skips_cover());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let repeat = ctx.params.b("repeatEdge");
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad((s * 3.0).ceil() as u32);
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(ctx.layer_size);
|
||||
}
|
||||
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).
|
||||
@@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if !repeat && !ctx.adjustment {
|
||||
let reach = if frac > 0.0 { r + 1 } else { r };
|
||||
b.pad((reach * it) as u32 + 1);
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(ctx.layer_size);
|
||||
}
|
||||
let edge = repeat || ctx.adjustment;
|
||||
let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge);
|
||||
|
||||
@@ -97,6 +97,21 @@ fn bubbles_rise() {
|
||||
assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}");
|
||||
}
|
||||
|
||||
/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands
|
||||
/// in for transparent ones.
|
||||
#[test]
|
||||
fn bubbles_near_edges_show_the_layer() {
|
||||
let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]);
|
||||
let pale = [0, 1].map(|reflection| {
|
||||
let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))];
|
||||
let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3);
|
||||
assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
|
||||
// Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha.
|
||||
a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count()
|
||||
});
|
||||
assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drizzle_ripples() {
|
||||
let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6);
|
||||
|
||||
@@ -182,7 +182,7 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
// Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror).
|
||||
let k = if metal { 1.6 } else { -0.7 };
|
||||
let nz = (1.0 - d2).max(0.0).sqrt();
|
||||
let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
|
||||
let p = src.sample_bilinear_clamped((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
|
||||
let (c, sa) = unpremul(p);
|
||||
let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c };
|
||||
let mut a = cov;
|
||||
|
||||
@@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[
|
||||
("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43),
|
||||
("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22),
|
||||
("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d),
|
||||
("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae),
|
||||
("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed),
|
||||
("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d),
|
||||
("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533),
|
||||
("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2),
|
||||
("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27),
|
||||
("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce),
|
||||
("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c),
|
||||
("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4),
|
||||
("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3),
|
||||
("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156),
|
||||
("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90),
|
||||
("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a),
|
||||
("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db),
|
||||
("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84),
|
||||
("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067),
|
||||
("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc),
|
||||
("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479),
|
||||
("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641),
|
||||
("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f),
|
||||
("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284),
|
||||
("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980),
|
||||
("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c),
|
||||
("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802),
|
||||
("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d),
|
||||
("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a),
|
||||
("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618),
|
||||
("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe),
|
||||
("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5),
|
||||
("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3),
|
||||
("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557),
|
||||
|
||||
@@ -125,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
|
||||
/// also become layers of the active comp ([`add_to_comp`]).
|
||||
/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's
|
||||
/// New Composition From Footage) they become one new composition instead ([`new_comp`]).
|
||||
///
|
||||
/// With `background` (files dropped on the window or picked in the Import dialog) the files are
|
||||
/// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows
|
||||
@@ -175,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`;
|
||||
/// what couldn't be added joins the result's errors.
|
||||
/// After an import (its result `r`): with `newComp`, the new items become one new composition;
|
||||
/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors.
|
||||
fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Value) {
|
||||
let errors = add_to_comp(s, r, target, p);
|
||||
let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) };
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
{
|
||||
@@ -192,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Val
|
||||
/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added.
|
||||
pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p: &Value) -> Vec<String> {
|
||||
let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] };
|
||||
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
|
||||
let comps = ids("comps");
|
||||
let in_comps: std::collections::HashSet<u64> = comps
|
||||
.iter()
|
||||
.filter_map(|c| s.project.comp(ItemId(*c)))
|
||||
.flat_map(|c| c.layers.iter())
|
||||
.filter_map(|l| match l.source {
|
||||
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let add: Vec<u64> = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect();
|
||||
let add = imported(s, r);
|
||||
let mut errors = vec![];
|
||||
for (k, item) in add.into_iter().enumerate() {
|
||||
let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")});
|
||||
@@ -220,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p:
|
||||
errors
|
||||
}
|
||||
|
||||
/// What an import (its result `r`) made that belongs in a comp: the comps of layered files
|
||||
/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items,
|
||||
/// without those comps again or the files' folders.
|
||||
fn imported(s: &Session, r: &Value) -> Vec<u64> {
|
||||
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
|
||||
let comps = ids("comps");
|
||||
let mut in_comps = std::collections::HashSet::new();
|
||||
let mut todo = comps.clone();
|
||||
while let Some(c) = todo.pop() {
|
||||
for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) {
|
||||
match l.source {
|
||||
LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0),
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } => {
|
||||
in_comps.insert(item.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder()));
|
||||
comps.iter().copied().chain(rest).collect()
|
||||
}
|
||||
|
||||
/// With `newComp`, make one composition of the footage and solids an import (its result `r`)
|
||||
/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp
|
||||
/// the import made (a layered file) already is the composition, so it stays out, as does data
|
||||
/// footage; none left, no new comp. Returns what went wrong.
|
||||
fn new_comp(s: &mut Session, r: &Value) -> Vec<String> {
|
||||
let items: Vec<ItemId> = imported(s, r)
|
||||
.into_iter()
|
||||
.map(ItemId)
|
||||
.filter(|i| {
|
||||
s.project.item(*i).is_some_and(|it| match &it.kind {
|
||||
ItemKind::Footage(f) => f.kind != FootageKind::Data,
|
||||
ItemKind::Solid(_) => true,
|
||||
ItemKind::Comp(_) | ItemKind::Folder => false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
s.state.project_selection = items;
|
||||
match s.execute("file.newCompFromSelection", json!({"single": true})) {
|
||||
Ok(_) => vec![],
|
||||
Err(e) => vec![e.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`].
|
||||
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pending = prepare_import(s, p)?;
|
||||
@@ -746,7 +785,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
always,
|
||||
import_cmd
|
||||
),
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
|
||||
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
|
||||
has_comp,
|
||||
new_text
|
||||
),
|
||||
@@ -192,7 +192,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
has_layers,
|
||||
set_text
|
||||
),
|
||||
|
||||
@@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn set_muted(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let muted = b_p(p, "muted").unwrap_or(true);
|
||||
let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" };
|
||||
edit_keys(s, label, None, move |keys, i, _| {
|
||||
keys[i].muted = muted;
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// A number or a per-dimension array of numbers.
|
||||
fn nums_p(p: &Value, k: &str, scale: f64) -> Option<Vec<f64>> {
|
||||
match p.get(k)? {
|
||||
@@ -1381,6 +1390,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
&json!({"which": "out"})
|
||||
)),
|
||||
cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold),
|
||||
cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted),
|
||||
cmd!(
|
||||
"keys.interpolation",
|
||||
"Keyframe Interpolation...",
|
||||
|
||||
@@ -1036,6 +1036,8 @@ Animation
|
||||
---
|
||||
Add Keyframe | anim.addKeyframe
|
||||
Toggle Hold Keyframe | keys.toggleHold
|
||||
Mute Keyframes | keys.setMuted
|
||||
Unmute Keyframes | keys.setMuted {"muted":false}
|
||||
Keyframe Interpolation... | keys.interpolation
|
||||
Keyframe Velocity... | keys.velocity
|
||||
Keyframe Assistant
|
||||
|
||||
@@ -1483,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muting_keys_skips_them_in_the_timeline_value_and_undoes() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64();
|
||||
let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
select(&mut s);
|
||||
let before = value_at(&s, 0.5);
|
||||
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
|
||||
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
|
||||
// The muted key no longer bends the 0 → 300 segment.
|
||||
assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6);
|
||||
assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6);
|
||||
s.undo();
|
||||
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
|
||||
assert!((value_at(&s, 0.5) - before).abs() < 1e-6);
|
||||
// The same command unmutes.
|
||||
select(&mut s);
|
||||
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
|
||||
s.execute("keys.setMuted", json!({"muted": false})).unwrap();
|
||||
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
|
||||
}
|
||||
|
||||
/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work.
|
||||
#[test]
|
||||
fn mute_and_unmute_keyframes_menu_rows_work_and_undo() {
|
||||
fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())),
|
||||
crate::menus::MenuNode::Submenu { children, .. } => rows(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut menu = vec![];
|
||||
rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu);
|
||||
assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}");
|
||||
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
let run = |s: &mut Session, params: &Value| {
|
||||
s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap();
|
||||
};
|
||||
run(&mut s, &menu[0].1);
|
||||
assert!(muted(&s));
|
||||
s.undo();
|
||||
assert!(!muted(&s));
|
||||
s.redo();
|
||||
assert!(muted(&s));
|
||||
run(&mut s, &menu[1].1);
|
||||
assert!(!muted(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() {
|
||||
assert_eq!(r["items"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what
|
||||
/// the import made, first file on top, at once or when the background job lands; an import that
|
||||
/// made nothing makes no comp.
|
||||
#[test]
|
||||
fn new_comp_from_footage_makes_a_comp_of_what_was_imported() {
|
||||
use effectcraft_project::{Footage, FootageKind, ItemKind};
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if path.ends_with(".xyz") {
|
||||
return Err("unsupported file".into());
|
||||
}
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
|
||||
let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count();
|
||||
let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
|
||||
// At once.
|
||||
s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap();
|
||||
assert_eq!(comps(&s), 1);
|
||||
assert_eq!(layers(&s), ["a.png"]);
|
||||
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32));
|
||||
// In the background: the job's own items, whatever is selected or active meanwhile; it takes
|
||||
// precedence over `addToComp`.
|
||||
s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap();
|
||||
s.state.project_selection.clear();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(comps(&s), 2);
|
||||
assert_eq!(layers(&s), ["c.png", "d.png"]);
|
||||
// Nothing imported: no comp, and the failure is reported.
|
||||
s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(comps(&s), 2);
|
||||
assert!(s.job_log.last().unwrap().message.contains("unsupported file"));
|
||||
let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap();
|
||||
assert_eq!(comps(&s), 2);
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
|
||||
#[test]
|
||||
fn audio_lock_and_shy_keep_cached_layers() {
|
||||
@@ -476,6 +516,9 @@ fn set_text_paragraph_fill_and_leading() {
|
||||
s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true));
|
||||
s.execute("layer.setText", json!({"layer": t, "lineJoin": "bevel"})).unwrap();
|
||||
assert_eq!(doc(&s).line_join, effectcraft_keyframe::LineJoin::Bevel);
|
||||
assert!(s.execute("layer.setText", json!({"layer": t, "lineJoin": "square"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -160,6 +160,37 @@ fn psd_imports_as_composition() {
|
||||
assert!(s.project.comp(cid).is_none());
|
||||
}
|
||||
|
||||
/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that
|
||||
/// composition; it isn't wrapped in another.
|
||||
#[test]
|
||||
fn psd_imported_as_composition_with_new_comp_is_the_new_comp() {
|
||||
let path = tmp("newcomp.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
|
||||
assert_eq!(s.state.active_comp, Some(cid));
|
||||
let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count();
|
||||
assert_eq!(roots, 1, "no wrapper comp");
|
||||
}
|
||||
|
||||
/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds
|
||||
/// its composition once, not its folder too.
|
||||
#[test]
|
||||
fn psd_imported_as_composition_into_a_comp_adds_one_layer() {
|
||||
let path = tmp("dropped.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let psd = r["comps"][0].as_u64().unwrap();
|
||||
let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers;
|
||||
assert_eq!(layers.len(), 1, "{layers:?}");
|
||||
assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_retain_layer_sizes() {
|
||||
let path = tmp("retain.psd");
|
||||
|
||||
+83
-14
@@ -31,6 +31,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
||||
__seed = 0;
|
||||
__timeless = false;
|
||||
__projectInfo = null;
|
||||
__layersTrim();
|
||||
}
|
||||
|
||||
function __finish(r) {
|
||||
@@ -44,7 +45,7 @@ function __finish(r) {
|
||||
|
||||
function __result(r) {
|
||||
r = __v(r);
|
||||
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
|
||||
if (typeof r === 'function' && r.__isLayer) return r.index;
|
||||
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();
|
||||
// `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while
|
||||
// host requests can still be answered and the script re-run.
|
||||
@@ -488,7 +489,7 @@ function __ancestor(c, l, path, countUp) {
|
||||
if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more');
|
||||
n = Math.floor(n);
|
||||
var info = __h(__ANCESTOR, c, l, path, n);
|
||||
if (info === 'layer') return new Layer(c, l);
|
||||
if (info === 'layer') return __layer(c, l);
|
||||
if (info === null) __err('propertyGroup(' + n + ') goes above the layer');
|
||||
return __mkGroup(c, l, info);
|
||||
}
|
||||
@@ -518,10 +519,10 @@ Comp.prototype = {
|
||||
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));
|
||||
if (typeof a === 'function' && a.__isLayer) return this.layer(a.index + (rel || 0));
|
||||
var id = __h(__LAYER, this.__c, a);
|
||||
if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range');
|
||||
return new Layer(this.__c, id);
|
||||
return __layer(this.__c, id);
|
||||
},
|
||||
toString: function () { return '[object Comp]'; },
|
||||
};
|
||||
@@ -596,7 +597,55 @@ var thisProject = {
|
||||
|
||||
// Layer -----------------------------------------------------------------------------------------
|
||||
|
||||
function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; }
|
||||
// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another layer's
|
||||
// `("effects")("Slider Control")`, `thisLayer(1)` (what the pick whip writes with Compact English
|
||||
// off). Callable like a group (`__mkGroup`), with the Layer methods.
|
||||
function Layer(c, l) {
|
||||
var f = function (k) { return f.__group(k); };
|
||||
delete f.name;
|
||||
delete f.length;
|
||||
Object.setPrototypeOf(f, Layer.prototype);
|
||||
// Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete
|
||||
// a field or leave its own on it. Its info is private to `__info`, which reads it again in an
|
||||
// evaluation whose `__S` isn't the one it was read in (every evaluation has a new `__S`), so
|
||||
// freezing the layer can't keep its info either.
|
||||
var info = null, read = null;
|
||||
Object.defineProperty(f, '__c', { value: c });
|
||||
Object.defineProperty(f, '__l', { value: l });
|
||||
Object.defineProperty(f, '__info', {
|
||||
get: function () {
|
||||
if (read !== __S) { info = __h(__LINFO, c, l); read = __S; }
|
||||
return info;
|
||||
},
|
||||
});
|
||||
Object.preventExtensions(f);
|
||||
return f;
|
||||
}
|
||||
|
||||
// Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain
|
||||
// object, so it is kept, and its info (`__info`) is read again in each evaluation.
|
||||
// The kept layers are out of an expression's reach (their maps have no prototype, so a change to
|
||||
// `Object.prototype` doesn't reach them either), and bounded like the runtime's scripts: once
|
||||
// `max` are kept, the layers after them aren't, and the next evaluation starts over
|
||||
// (`__layersTrim`, so a layer stays the same object for a whole evaluation).
|
||||
var __layer, __layersTrim;
|
||||
(function () {
|
||||
var layers = Object.create(null), kept = 0, max = 4096;
|
||||
__layer = function (c, l) {
|
||||
// By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves.
|
||||
var m = layers[c], f = m && m[l];
|
||||
if (f) return f;
|
||||
f = new Layer(c, l);
|
||||
if (kept < max) {
|
||||
(m || (layers[c] = Object.create(null)))[l] = f;
|
||||
kept++;
|
||||
}
|
||||
return f;
|
||||
};
|
||||
__layersTrim = function () {
|
||||
if (kept >= max) { layers = Object.create(null); kept = 0; }
|
||||
};
|
||||
})();
|
||||
|
||||
function __m3(m, p) {
|
||||
var x = p[0] || 0, y = p[1] || 0;
|
||||
@@ -620,7 +669,6 @@ function __m4(m, p, vec) {
|
||||
|
||||
Layer.prototype = {
|
||||
__isLayer: true,
|
||||
get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); },
|
||||
get name() { return this.__info[0]; },
|
||||
get index() { return this.__info[1]; },
|
||||
get inPoint() { return this.__info[2]; },
|
||||
@@ -632,7 +680,7 @@ Layer.prototype = {
|
||||
get parent() {
|
||||
var p = this.__info[7];
|
||||
if (p === null) __err('Layer "' + this.name + '" has no parent');
|
||||
return new Layer(this.__c, p);
|
||||
return __layer(this.__c, p);
|
||||
},
|
||||
get hasVideo() { return this.__info[8]; },
|
||||
get enabled() { return this.__info[11]; },
|
||||
@@ -804,7 +852,7 @@ Prop.prototype = {
|
||||
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
|
||||
__wrap: function (v) {
|
||||
var kind = this.__info[2];
|
||||
if (kind === 'layer') return v === null ? null : new Layer(this.__c, v);
|
||||
if (kind === 'layer') return v === null ? null : __layer(this.__c, v);
|
||||
if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]);
|
||||
return v;
|
||||
},
|
||||
@@ -834,7 +882,7 @@ Prop.prototype = {
|
||||
for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
|
||||
return this.key(best + 1);
|
||||
},
|
||||
__seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; },
|
||||
__seedIndex: function () { return this.__self ? __S.idx : __layer(this.__c, this.__l).index; },
|
||||
wiggle: function (freq, amp, octaves, ampMult, t) {
|
||||
if (octaves === undefined) octaves = 1;
|
||||
if (ampMult === undefined) ampMult = 0.5;
|
||||
@@ -903,8 +951,9 @@ var __propHandler = {
|
||||
var v = t.value;
|
||||
if (v === null || v === undefined) return undefined;
|
||||
var x = v[k];
|
||||
// Property groups are callable proxies: hand them over as they are, not bound like methods (#809).
|
||||
if (x?.__isGroup) return x;
|
||||
// Property groups and layers are callable: hand them over as they are, not bound like methods
|
||||
// (#809).
|
||||
if (typeof x === 'function' && (x.__isGroup || x.__isLayer)) return x;
|
||||
return typeof x === 'function' ? x.bind(v) : x;
|
||||
},
|
||||
};
|
||||
@@ -1082,18 +1131,18 @@ Markers.prototype = {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're
|
||||
// accessors on the global object; assigning one (`var width = 10`) shadows it for the current
|
||||
// evaluation only.
|
||||
// evaluation only. They can't be deleted or redefined, so an expression can't take one away from
|
||||
// the evaluations after it.
|
||||
|
||||
function __global(name, get) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
get: function () { return name in __S.vars ? __S.vars[name] : get(); },
|
||||
set: function (v) { __S.vars[name] = v; },
|
||||
});
|
||||
}
|
||||
|
||||
__global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); });
|
||||
__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); });
|
||||
__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); });
|
||||
__global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); });
|
||||
['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active',
|
||||
'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) {
|
||||
@@ -1226,3 +1275,23 @@ function nearestKey(t) { return thisProperty.nearestKey(t); }
|
||||
delete globalThis.Proxy;
|
||||
delete globalThis.Reflect;
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The runtime runs this file once and keeps it for the thread's later evaluations, so an
|
||||
// expression can't change the object model or the internals: the prototypes are frozen, and the
|
||||
// `__` globals, the constructors and `thisProject` can't be reassigned. The `__` state below is
|
||||
// set again by `__begin` in each evaluation.
|
||||
|
||||
[Comp, Footage, Layer, Prop, Markers, __Path, __TextStyle].forEach(function (f) {
|
||||
Object.freeze(f.prototype);
|
||||
Object.freeze(f);
|
||||
});
|
||||
[__GroupProto, __groupHandler, __propHandler, thisProject].forEach(Object.freeze);
|
||||
(function () {
|
||||
var perEvaluation = { __S: true, __rc: true, __seed: true, __timeless: true, __projectInfo: true };
|
||||
var model = { Comp: true, Footage: true, Layer: true, Prop: true, Markers: true, thisProject: true };
|
||||
Object.getOwnPropertyNames(globalThis).forEach(function (k) {
|
||||
if (!(k.slice(0, 2) === '__' && !perEvaluation[k]) && !model[k]) return;
|
||||
if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false });
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -677,6 +677,7 @@ pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) ->
|
||||
let p0 = src.para(0);
|
||||
d.set_paras(vec![p0; d.para_count()]);
|
||||
d.stroke_over_fill = src.stroke_over_fill;
|
||||
d.line_join = src.line_join;
|
||||
}
|
||||
}
|
||||
for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() {
|
||||
|
||||
@@ -1099,6 +1099,8 @@ fn shadowing_layer_attribute_names_is_local() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0);
|
||||
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
|
||||
assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0);
|
||||
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1493,4 +1495,97 @@ fn time_to_frames_preserves_coercion_and_small_durations() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another
|
||||
/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact
|
||||
/// English off (they failed: "not a callable function"). It is still a layer.
|
||||
#[test]
|
||||
fn layers_are_callable_property_groups() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0);
|
||||
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0);
|
||||
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0);
|
||||
assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0);
|
||||
// Its own name, index and methods, and a layer as the result still reads as its index.
|
||||
assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0);
|
||||
assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0);
|
||||
assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0);
|
||||
assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")"));
|
||||
}
|
||||
|
||||
/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project
|
||||
/// afresh in each, and an expression's own fields on it don't reach the next one.
|
||||
#[test]
|
||||
fn a_kept_layer_reads_the_project_again() {
|
||||
let mut f = fx();
|
||||
let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length";
|
||||
assert_close!(f.num(first, 0.0), 21.0);
|
||||
f.layer_mut(f.b).name = "Bee".into();
|
||||
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
|
||||
assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0);
|
||||
assert_close!(f.num("thisLayer.index", 0.0), 2.0);
|
||||
let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0);
|
||||
assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0);
|
||||
let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0);
|
||||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
|
||||
let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0);
|
||||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
|
||||
let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0);
|
||||
f.layer_mut(f.a).name = "Ay".into();
|
||||
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
|
||||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0);
|
||||
}
|
||||
|
||||
/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more
|
||||
/// layers than are kept still sees one object for each layer it had kept, and the evaluations
|
||||
/// after it, which start over, read the right layers.
|
||||
#[test]
|
||||
fn the_kept_layers_are_bounded() {
|
||||
let f = fx();
|
||||
let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)";
|
||||
let many =
|
||||
format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)");
|
||||
assert_close!(f.num(&many, 0.0), 11.0);
|
||||
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
|
||||
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
|
||||
}
|
||||
|
||||
/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under
|
||||
/// the comp's id, before any of its layers is kept, doesn't stop them being kept.
|
||||
#[test]
|
||||
fn the_kept_layers_dont_go_through_object_prototype() {
|
||||
let f = fx();
|
||||
let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0);
|
||||
assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// The prelude is kept from one evaluation to the next, so an expression can't change its object
|
||||
/// model or internals for the ones after it.
|
||||
#[test]
|
||||
fn an_expression_cant_change_the_prelude_for_the_next() {
|
||||
let f = fx();
|
||||
let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]";
|
||||
let before = f.ev(f.a, ROT, read, 0.5).unwrap();
|
||||
let attacks = [
|
||||
"Comp.prototype.layer = function () { return 0; }",
|
||||
"Object.getPrototypeOf(thisLayer).index = 0",
|
||||
"Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }",
|
||||
"Object.getPrototypeOf(thisLayer.transform.opacity).value = 0",
|
||||
"Comp = function () {}",
|
||||
"Prop = function () {}",
|
||||
"thisProject = null",
|
||||
"__h = function () { return null; }",
|
||||
"__layer = function () { return null; }",
|
||||
"__child = function () {}",
|
||||
"delete __mkProp",
|
||||
"delete thisLayer",
|
||||
"delete thisComp",
|
||||
"delete width",
|
||||
"Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })",
|
||||
];
|
||||
for a in attacks {
|
||||
let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0);
|
||||
assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`");
|
||||
}
|
||||
}
|
||||
|
||||
mod property_group;
|
||||
|
||||
@@ -72,6 +72,9 @@ fn property_group_reaches_the_layer() {
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
// The layer it reaches is the same object as `thisLayer`.
|
||||
assert_close!(f.num("thisLayer('transform').propertyGroup(1) === thisLayer ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2) === thisLayer ? 1 : 0", 0.0), 1.0);
|
||||
let s = staggered();
|
||||
assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,20 @@ pub(crate) struct GBuf {
|
||||
}
|
||||
|
||||
impl GBuf {
|
||||
/// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it.
|
||||
pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> {
|
||||
let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) };
|
||||
if !e.g.fits(w, h) {
|
||||
return None;
|
||||
}
|
||||
let out = e.image(w, h);
|
||||
e.copy_into(&self.img, &out, dx, dy);
|
||||
self.img = out;
|
||||
self.offset[0] += dx as f64;
|
||||
self.offset[1] += dy as f64;
|
||||
Some(())
|
||||
}
|
||||
|
||||
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
|
||||
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
|
||||
}
|
||||
@@ -46,6 +60,7 @@ impl GBuf {
|
||||
|
||||
/// Buf::pad: grow by `pad` transparent pixels on every side.
|
||||
pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> {
|
||||
let pad = effectcraft_effects::pad_within_budget(self.img.width, self.img.height, pad);
|
||||
if pad == 0 {
|
||||
return Some(());
|
||||
}
|
||||
@@ -166,12 +181,23 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
|
||||
/// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no
|
||||
/// readback. A region the device can't allocate leaves the buffer as it is.
|
||||
fn cover(&mut self, size: [f64; 2]) {
|
||||
let Some(mut b) = self.b.take() else { return };
|
||||
let keep = (b.img.clone(), b.offset);
|
||||
if b.cover(self.e, size).is_none() {
|
||||
(b.img, b.offset) = keep;
|
||||
}
|
||||
self.b = Some(b);
|
||||
}
|
||||
|
||||
/// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one
|
||||
/// copied in. A size the device can't allocate leaves the buffer as it is.
|
||||
fn align(&mut self, size: [f64; 2]) {
|
||||
let Some(b) = self.b.take() else { return };
|
||||
let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) {
|
||||
Some((dx, dy, w, h)) if self.e.g.fits(w, h) => {
|
||||
let img = self.e.image(w, h);
|
||||
self.e.copy_into(&b.img, &img, dx, dy);
|
||||
GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale }
|
||||
let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) {
|
||||
Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => {
|
||||
let img = self.e.image(b.img.width + l, b.img.height + t);
|
||||
self.e.copy_into(&b.img, &img, l, t);
|
||||
GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale }
|
||||
}
|
||||
_ => b,
|
||||
};
|
||||
@@ -291,6 +317,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let repeat = ctx.params.b("repeatEdge");
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad(e, (s * 3.0).ceil() as u32)?;
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(e, ctx.layer_size)?;
|
||||
}
|
||||
b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment);
|
||||
Some(b)
|
||||
@@ -311,6 +340,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let repeat = ctx.params.b("repeatEdge");
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad(e, (r * it) as u32 + 1)?;
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(e, ctx.layer_size)?;
|
||||
}
|
||||
b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment);
|
||||
Some(b)
|
||||
|
||||
@@ -180,6 +180,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let size = (ctx.params.f("size") * b.scale).max(1.0);
|
||||
if ctx.params.b("resizeLayer") && !ctx.adjustment {
|
||||
b.pad(e, amount.abs().ceil() as u32 + 1)?;
|
||||
} else {
|
||||
// Room for pixels displaced past the content, up to the layer's edge (#1205).
|
||||
b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?;
|
||||
}
|
||||
let off = b.to_px(ctx.params.v2("offset"));
|
||||
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
|
||||
|
||||
@@ -180,7 +180,7 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
}
|
||||
let k = select(-0.7, 1.6, P.u[1].y != 0u);
|
||||
let nz = sqrt(max(1.0 - d2, 0.0));
|
||||
let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k));
|
||||
let s = fxm_unpremul(sample_bilinear_clamped(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k));
|
||||
var c = s.xyz;
|
||||
if (s.w <= 0.0) {
|
||||
c = vec3<f32>(0.6, 0.7, 0.8);
|
||||
|
||||
@@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat
|
||||
return Some(format!("blend mode {}", l.blend_mode.label()));
|
||||
}
|
||||
// Premiere's Basic Motion in the interchange formats has one (uniform) scale.
|
||||
if let Some(s) = l.transform().and_then(|t| t.get("scale"))
|
||||
&& std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() > 1e-9
|
||||
})
|
||||
{
|
||||
if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) {
|
||||
return Some("non-uniform scale".into());
|
||||
}
|
||||
if l.preserve_transparency {
|
||||
@@ -581,10 +576,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
if let Some(p) = tr.get("scale") {
|
||||
let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() < 1e-9
|
||||
});
|
||||
let uniform = !non_uniform_scale(p);
|
||||
m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1])));
|
||||
if !uniform {
|
||||
m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0])));
|
||||
@@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool {
|
||||
(a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6
|
||||
}
|
||||
|
||||
/// Whether a scale property is non-uniform at some point: active key values, or the static
|
||||
/// value when every key is muted (muted keys are not exported, #1894).
|
||||
fn non_uniform_scale(p: &Property) -> bool {
|
||||
let non_uniform = |v: &Value| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() > 1e-9
|
||||
};
|
||||
if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) }
|
||||
}
|
||||
|
||||
/// The clip's `id` effect, or a new instance of it (None if the registry has no such effect).
|
||||
fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option<fp::EffectInstance> {
|
||||
ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance()))
|
||||
@@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) {
|
||||
/// A property → a FilmCraft parameter (layer-time keys → clip-time keys).
|
||||
fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param {
|
||||
let mut out = fp::Param::new(conv(&p.value));
|
||||
for k in &p.keys {
|
||||
// XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so
|
||||
// the exported animation matches what the renderer shows (#1894).
|
||||
for k in p.keys.iter().filter(|k| !k.muted) {
|
||||
let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value));
|
||||
fk.interp = match k.out_interp {
|
||||
Interp::Linear => fp::Interpolation::Linear,
|
||||
|
||||
@@ -480,6 +480,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property {
|
||||
l.transform().unwrap().get("position").unwrap()
|
||||
}
|
||||
|
||||
/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe
|
||||
/// (the export would otherwise animate through it).
|
||||
#[test]
|
||||
fn export_skips_muted_keyframes() {
|
||||
let mut p = Project::default();
|
||||
let a = footage_item(&mut p, "/media/a.mov");
|
||||
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0));
|
||||
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
|
||||
{
|
||||
let pos = l.transform_mut().unwrap().get_mut("position").unwrap();
|
||||
pos.keys = vec![
|
||||
Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])),
|
||||
Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])),
|
||||
Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])),
|
||||
];
|
||||
pos.keys[1].muted = true;
|
||||
}
|
||||
c.layers.push(l);
|
||||
let cid = comp_item(&mut p, "Main", c);
|
||||
let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap();
|
||||
let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe);
|
||||
let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main");
|
||||
let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap();
|
||||
assert_eq!(pos.keys.len(), 2, "the muted key was exported");
|
||||
assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0);
|
||||
assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0);
|
||||
}
|
||||
|
||||
/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the
|
||||
/// active keys matter, and the static value when every key is muted.
|
||||
#[test]
|
||||
fn muted_non_uniform_scale_does_not_force_prerendering() {
|
||||
let mut p = Project::default();
|
||||
let a = footage_item(&mut p, "/media/a.mov");
|
||||
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0));
|
||||
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
|
||||
{
|
||||
let scale = l.transform_mut().unwrap().get_mut("scale").unwrap();
|
||||
scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))];
|
||||
scale.keys[1].muted = true;
|
||||
}
|
||||
let lid = l.id;
|
||||
c.layers.push(l);
|
||||
let cid = comp_item(&mut p, "Main", c);
|
||||
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None);
|
||||
// With every key muted the static value drives evaluation, so a non-uniform static scale
|
||||
// still takes the flattened path.
|
||||
let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap();
|
||||
scale.keys[0].muted = true;
|
||||
scale.value = Value::Vec3([50.0, 200.0, 100.0]);
|
||||
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_without_prerender_leaves_text_out() {
|
||||
let (p, cid) = ae_project();
|
||||
|
||||
+52
-21
@@ -15,7 +15,8 @@ pub mod value;
|
||||
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun};
|
||||
use std::borrow::Borrow;
|
||||
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun};
|
||||
pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value};
|
||||
|
||||
/// Temporal interpolation on one side of a keyframe.
|
||||
@@ -89,12 +90,19 @@ pub struct Keyframe {
|
||||
/// Keyframe color label: index into the label list (0 = None).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub label: u8,
|
||||
/// Muted keys stay in the list (and the UI) but evaluation skips them.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub muted: bool,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u8) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -116,6 +124,7 @@ impl Keyframe {
|
||||
spatial_continuous: false,
|
||||
roving: false,
|
||||
label: 0,
|
||||
muted: false,
|
||||
}
|
||||
}
|
||||
pub fn hold(mut self) -> Keyframe {
|
||||
@@ -320,16 +329,16 @@ fn v3(v: &Value) -> [f64; 3] {
|
||||
/// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and
|
||||
/// key times play no part. An end key uses its one neighbour, so the path leaves the first key
|
||||
/// and enters the last one toward the neighbouring key instead of with no tangent.
|
||||
fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||||
fn auto_spatial<K: Borrow<Keyframe>>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) {
|
||||
if i >= keys.len() || keys.len() < 2 {
|
||||
return ([0.0; 3], [0.0; 3]);
|
||||
}
|
||||
let a = v3(&keys[i.saturating_sub(1)].value);
|
||||
let c = v3(&keys[(i + 1).min(keys.len() - 1)].value);
|
||||
let a = v3(&keys[i.saturating_sub(1)].borrow().value);
|
||||
let c = v3(&keys[(i + 1).min(keys.len() - 1)].borrow().value);
|
||||
let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0];
|
||||
// A side facing a key at the same position gets no tangent, so the segment between two
|
||||
// identical keys has zero length and the layer holds still (#1675).
|
||||
let p = v3(&keys[i].value);
|
||||
let p = v3(&keys[i].borrow().value);
|
||||
let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] };
|
||||
let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout };
|
||||
(tin, tout)
|
||||
@@ -337,7 +346,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||||
|
||||
/// Effective spatial tangents of key `i` (auto or stored).
|
||||
pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||||
let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) };
|
||||
tangents_of(keys, i)
|
||||
}
|
||||
|
||||
/// [`spatial_tangents`] over stored keys or a filtered list of them (see [`evaluate`]).
|
||||
fn tangents_of<K: Borrow<Keyframe>>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) {
|
||||
let Some(key) = keys.get(i).map(Borrow::borrow) else { return ([0.0; 3], [0.0; 3]) };
|
||||
if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) }
|
||||
}
|
||||
|
||||
@@ -373,18 +387,18 @@ pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||||
}
|
||||
|
||||
/// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope).
|
||||
fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease {
|
||||
let k = &keys[i];
|
||||
fn effective_ease<K: Borrow<Keyframe>>(keys: &[K], i: usize, d: usize, spatial: bool, out: bool) -> Ease {
|
||||
let k = keys[i].borrow();
|
||||
let list = if out { &k.out_ease } else { &k.in_ease };
|
||||
if k.auto_bezier && i > 0 && i + 1 < keys.len() {
|
||||
let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time);
|
||||
let dt = secs(keys[i + 1].borrow().time) - secs(keys[i - 1].borrow().time);
|
||||
let slope = if spatial {
|
||||
let a = v3(&keys[i - 1].value);
|
||||
let c = v3(&keys[i + 1].value);
|
||||
let a = v3(&keys[i - 1].borrow().value);
|
||||
let c = v3(&keys[i + 1].borrow().value);
|
||||
((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12)
|
||||
} else {
|
||||
let a = keys[i - 1].value.components();
|
||||
let c = keys[i + 1].value.components();
|
||||
let a = keys[i - 1].borrow().value.components();
|
||||
let c = keys[i + 1].borrow().value.components();
|
||||
(c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12)
|
||||
};
|
||||
return Ease { speed: slope, influence: 1.0 / 6.0 };
|
||||
@@ -508,23 +522,39 @@ pub fn time_reverse(keys: &mut [Keyframe]) {
|
||||
}
|
||||
|
||||
/// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values.
|
||||
/// Muted keys are skipped, so the active neighbours interpolate as if they were not there; `None`
|
||||
/// means every key is muted (the property's static value applies).
|
||||
pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option<Value> {
|
||||
let first = keys.first()?;
|
||||
if !keys.iter().any(|k| k.muted) {
|
||||
return evaluate_active(keys, t, spatial);
|
||||
}
|
||||
// Muting is rare: skip the muted keys by reference rather than copying every key and value.
|
||||
let active: Vec<&Keyframe> = keys.iter().filter(|k| !k.muted).collect();
|
||||
evaluate_active(&active, t, spatial)
|
||||
}
|
||||
|
||||
fn evaluate_active<K: Borrow<Keyframe>>(keys: &[K], t: Tick, spatial: bool) -> Option<Value> {
|
||||
let first = keys.first()?.borrow();
|
||||
if keys.len() == 1 || t <= first.time {
|
||||
return Some(first.value.clone());
|
||||
}
|
||||
let last = keys.last()?;
|
||||
let last = keys.last()?.borrow();
|
||||
if t >= last.time {
|
||||
return Some(last.value.clone());
|
||||
}
|
||||
let i = keys.partition_point(|k| k.time <= t) - 1;
|
||||
Some(segment_value(keys, i, t, spatial))
|
||||
let i = keys.partition_point(|k| k.borrow().time <= t) - 1;
|
||||
Some(segment_value_of(keys, i, t, spatial))
|
||||
}
|
||||
|
||||
/// Value inside segment `i` → `i+1` at time `t`.
|
||||
pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value {
|
||||
let a = &keys[i];
|
||||
let b = &keys[i + 1];
|
||||
segment_value_of(keys, i, t, spatial)
|
||||
}
|
||||
|
||||
/// [`segment_value`] over stored keys or a filtered list of them (see [`evaluate`]).
|
||||
fn segment_value_of<K: Borrow<Keyframe>>(keys: &[K], i: usize, t: Tick, spatial: bool) -> Value {
|
||||
let a = keys[i].borrow();
|
||||
let b = keys[i + 1].borrow();
|
||||
if a.out_interp == Interp::Hold || !a.value.interpolates() {
|
||||
return a.value.clone();
|
||||
}
|
||||
@@ -539,8 +569,8 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val
|
||||
if is_spatial {
|
||||
let p0 = v3(&a.value);
|
||||
let p3 = v3(&b.value);
|
||||
let (_, out_t) = spatial_tangents(keys, i);
|
||||
let (in_t, _) = spatial_tangents(keys, i + 1);
|
||||
let (_, out_t) = tangents_of(keys, i);
|
||||
let (in_t, _) = tangents_of(keys, i + 1);
|
||||
let seg = SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]);
|
||||
let len = seg.length();
|
||||
let f = if lin_out && lin_in || len <= 1e-12 {
|
||||
@@ -617,6 +647,7 @@ pub fn set_key(keys: &mut Vec<Keyframe>, key: Keyframe) -> usize {
|
||||
k.spatial_continuous = old.spatial_continuous;
|
||||
k.roving = old.roving;
|
||||
k.label = old.label;
|
||||
k.muted = old.muted;
|
||||
keys[i] = k;
|
||||
i
|
||||
}
|
||||
|
||||
@@ -395,3 +395,59 @@ proptest::proptest! {
|
||||
proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muted_keys_are_skipped_by_evaluation() {
|
||||
let mut keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0)), Keyframe::new(s(2.0), Value::Scalar(300.0))];
|
||||
let at = |keys: &[Keyframe], t: f64| evaluate(keys, s(t), false).unwrap().as_f64();
|
||||
assert!((at(&keys, 0.5) - 50.0).abs() < 1e-9);
|
||||
assert!((at(&keys, 1.5) - 200.0).abs() < 1e-9);
|
||||
keys[1].muted = true;
|
||||
// The 0 → 300 segment now spans 0 → 2 s.
|
||||
assert!((at(&keys, 0.5) - 75.0).abs() < 1e-9);
|
||||
assert!((at(&keys, 1.0) - 150.0).abs() < 1e-9);
|
||||
assert!((at(&keys, 1.5) - 225.0).abs() < 1e-9);
|
||||
for k in &mut keys {
|
||||
k.muted = true;
|
||||
}
|
||||
assert!(evaluate(&keys, s(0.5), false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muted_key_automation_uses_the_active_neighbours() {
|
||||
let mut keys = vec![
|
||||
Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])),
|
||||
Keyframe::new(s(1.0), Value::Vec2([50.0, 100.0])),
|
||||
Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])),
|
||||
];
|
||||
for k in &mut keys {
|
||||
k.auto_bezier = true;
|
||||
k.in_interp = Interp::Bezier;
|
||||
k.out_interp = Interp::Bezier;
|
||||
}
|
||||
let all = keys.clone();
|
||||
let mut active = keys.clone();
|
||||
active.remove(1);
|
||||
keys[1].muted = true;
|
||||
for t in [0.0, 0.25, 0.5, 1.0, 1.5, 1.75, 2.0] {
|
||||
assert_eq!(evaluate(&keys, s(t), true), evaluate(&active, s(t), true), "{t}");
|
||||
}
|
||||
// The muted auto-Bezier key no longer bulges the motion path.
|
||||
assert_ne!(evaluate(&keys, s(0.5), true), evaluate(&all, s(0.5), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mute_round_trips_and_survives_value_replacement() {
|
||||
let mut k = Keyframe::new(s(0.0), Value::Scalar(1.0));
|
||||
let json = serde_json::to_value(&k).unwrap();
|
||||
assert!(json.get("muted").is_none(), "false is the default and is skipped");
|
||||
k.muted = true;
|
||||
let json = serde_json::to_value(&k).unwrap();
|
||||
assert_eq!(json["muted"], serde_json::json!(true));
|
||||
let back: Keyframe = serde_json::from_value(json).unwrap();
|
||||
assert!(back.muted);
|
||||
let mut keys = vec![k];
|
||||
set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(2.0)));
|
||||
assert!(keys[0].muted);
|
||||
assert_eq!(keys[0].value, Value::Scalar(2.0));
|
||||
}
|
||||
|
||||
@@ -179,6 +179,31 @@ pub enum Composer {
|
||||
fn single_line() -> Composer {
|
||||
Composer::SingleLine
|
||||
}
|
||||
|
||||
/// How the corners of a text stroke are drawn (Character panel Line Join).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum LineJoin {
|
||||
/// Sharp corners (AE's default for new text).
|
||||
#[default]
|
||||
Miter,
|
||||
Round,
|
||||
Bevel,
|
||||
}
|
||||
|
||||
impl LineJoin {
|
||||
pub fn parse(s: &str) -> Option<LineJoin> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"miter" => LineJoin::Miter,
|
||||
"round" => LineJoin::Round,
|
||||
"bevel" => LineJoin::Bevel,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn round_join() -> LineJoin {
|
||||
LineJoin::Round
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -402,6 +427,9 @@ pub struct TextDoc {
|
||||
pub apply_fill: bool,
|
||||
pub apply_stroke: bool,
|
||||
pub stroke_over_fill: bool,
|
||||
/// Documents saved before line joins existed drew round joins.
|
||||
#[serde(default = "round_join")]
|
||||
pub line_join: LineJoin,
|
||||
pub tracking: f64,
|
||||
/// None = auto (120% of size).
|
||||
pub leading: Option<f64>,
|
||||
@@ -463,6 +491,7 @@ impl Default for TextDoc {
|
||||
apply_fill: true,
|
||||
apply_stroke: false,
|
||||
stroke_over_fill: true,
|
||||
line_join: LineJoin::Miter,
|
||||
tracking: 0.0,
|
||||
leading: None,
|
||||
justify: Justify::Left,
|
||||
@@ -909,6 +938,10 @@ impl TextDoc {
|
||||
self.stroke_over_fill = v.as_bool().ok_or("strokeOverFill: expected a boolean")?;
|
||||
return Ok(true);
|
||||
}
|
||||
"lineJoin" => {
|
||||
self.line_join = v.as_str().and_then(LineJoin::parse).ok_or("lineJoin: expected miter|round|bevel")?;
|
||||
return Ok(true);
|
||||
}
|
||||
"vertical" => {
|
||||
self.vertical = v.as_bool().ok_or("vertical: expected a boolean")?;
|
||||
return Ok(true);
|
||||
@@ -1423,6 +1456,8 @@ mod tests {
|
||||
assert!(d.is_uniform());
|
||||
assert_eq!(d.style_at(2).size, 50.0);
|
||||
assert_eq!(d.composer, Composer::SingleLine, "old box text keeps greedy breaking");
|
||||
assert_eq!(d.line_join, LineJoin::Round, "old strokes keep their round corners");
|
||||
assert_eq!(TextDoc::default().line_join, LineJoin::Miter);
|
||||
assert!(d.ligatures);
|
||||
assert_eq!(d.kerning, Kerning::Metrics);
|
||||
// Round trip with runs.
|
||||
|
||||
@@ -73,9 +73,22 @@ fn is_spatial(p: &Property) -> bool {
|
||||
}
|
||||
|
||||
/// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions).
|
||||
/// `label` names the property in warnings.
|
||||
/// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and
|
||||
/// the remaining keys carry the animation the renderer shows.
|
||||
pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json {
|
||||
let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) };
|
||||
let muted = p.keys.iter().any(|k| k.muted);
|
||||
if muted {
|
||||
ex.warn(format!("{label}: muted keyframes are not exported"));
|
||||
}
|
||||
let active: Vec<Keyframe>;
|
||||
let keys: &[Keyframe] = if muted {
|
||||
active = p.keys.iter().filter(|k| !k.muted).cloned().collect();
|
||||
&active
|
||||
} else {
|
||||
&p.keys
|
||||
};
|
||||
let mut out =
|
||||
if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) };
|
||||
if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) {
|
||||
if ex.include_expressions {
|
||||
out["x"] = json!(e.text);
|
||||
@@ -86,20 +99,18 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js
|
||||
out
|
||||
}
|
||||
|
||||
fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec<Json> {
|
||||
let keys = &p.keys;
|
||||
let spatial = is_spatial(p);
|
||||
fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec<Json> {
|
||||
let mut out = Vec::with_capacity(keys.len());
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))});
|
||||
if i + 1 == keys.len() {
|
||||
if k.out_interp == Interp::Hold || p.hold_only {
|
||||
if k.out_interp == Interp::Hold || hold_only {
|
||||
o["h"] = json!(1);
|
||||
}
|
||||
out.push(o);
|
||||
continue;
|
||||
}
|
||||
if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() {
|
||||
if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() {
|
||||
o["h"] = json!(1);
|
||||
out.push(o);
|
||||
continue;
|
||||
|
||||
@@ -878,3 +878,21 @@ fn external_images_are_local_or_skipped() {
|
||||
assert_eq!(std::path::Path::new(&paths[0]), base.join("images").join("a.png"));
|
||||
assert_eq!(r.warnings.iter().filter(|w| w.contains("is not imported")).count(), assets.len() - 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muted_keys_are_omitted_from_the_export() {
|
||||
let (mut p, cid) = setup();
|
||||
let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20);
|
||||
let mut b = Keyframe::new(s(1.0), Value::Scalar(100.0));
|
||||
b.muted = true;
|
||||
l.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), b, Keyframe::new(s(2.0), Value::Scalar(300.0))];
|
||||
push(&mut p, cid, l);
|
||||
let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||
assert!(warnings.iter().any(|w| w.contains("muted keyframes")), "{warnings:?}");
|
||||
// Lottie has no mute state: the two active keys carry the 0 → 300 animation.
|
||||
assert_eq!(json["layers"][0]["ks"]["o"]["k"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(json["layers"][0]["ks"]["o"]["k"][1]["s"], json!([300.0]));
|
||||
let opacity_at = |p: &Project, cid: ItemId, t: f64| p.comp(cid).unwrap().layers[0].props.prop("transform/opacity").unwrap().value_at(s(t)).as_f64();
|
||||
assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6);
|
||||
assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn);
|
||||
turn_footage(&mut f, turn);
|
||||
correct_rate(&mut f, header.as_deref().and_then(video_rate));
|
||||
trim_to_edits(&mut f, header.as_deref().and_then(video_shown));
|
||||
return Ok(f);
|
||||
}
|
||||
let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
|
||||
@@ -107,6 +108,7 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let turn = video_turn(&bytes);
|
||||
let rate = video_rate(&bytes);
|
||||
let shown = video_shown(&bytes);
|
||||
// A WAV's cue points and XMP markers become the footage's markers.
|
||||
let markers = crate::wav_markers::read(&bytes);
|
||||
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
|
||||
@@ -114,9 +116,37 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
|
||||
f.markers = markers;
|
||||
turn_footage(&mut f, turn);
|
||||
correct_rate(&mut f, rate);
|
||||
trim_to_edits(&mut f, shown);
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// A movie lasts as long as its video edit list shows (#2592): the reader reports the media
|
||||
/// duration (`mdhd`), which for H.264 with B-frames runs past the last shown frame by the
|
||||
/// encoder delay the edit list trims (75 frames at 25 fps read as 3.04 s). Never lengthens.
|
||||
fn trim_to_edits(f: &mut Footage, shown: Option<Tick>) {
|
||||
if f.kind == FootageKind::Video
|
||||
&& let Some(shown) = shown.filter(|s| *s > Tick::ZERO && *s < f.duration)
|
||||
{
|
||||
f.duration = shown;
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the video track's edit list shows its media (`elst` segments, movie timescale):
|
||||
/// `None` without edits, or with an empty edit (a delayed start keeps the reader's duration).
|
||||
fn video_shown(bytes: &[u8]) -> Option<Tick> {
|
||||
let file = filmcraft_isobmff::open(bytes).ok()?;
|
||||
let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?;
|
||||
edits_shown(&t.edits, file.timescale)
|
||||
}
|
||||
|
||||
fn edits_shown(edits: &[filmcraft_isobmff::Edit], timescale: u32) -> Option<Tick> {
|
||||
if edits.is_empty() || timescale == 0 || edits.iter().any(|e| e.media_time < 0) {
|
||||
return None;
|
||||
}
|
||||
let units = edits.iter().try_fold(0u64, |acc, e| acc.checked_add(e.segment_duration))?;
|
||||
Some(Tick::from_units(i64::try_from(units).ok()?, i64::from(timescale)))
|
||||
}
|
||||
|
||||
/// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's
|
||||
/// own estimate misreads it.
|
||||
fn correct_rate(f: &mut Footage, rate: Option<FrameRate>) {
|
||||
@@ -322,6 +352,24 @@ fn averaged_rate(timescale: u32, durations: impl Iterator<Item = u32>) -> Option
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a
|
||||
/// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it.
|
||||
#[test]
|
||||
fn the_edit_list_trims_a_movie_to_the_frames_it_shows() {
|
||||
let edit = |segment_duration, media_time| filmcraft_isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 };
|
||||
let shown = edits_shown(&[edit(3000, 1024)], 1000);
|
||||
assert_eq!(shown, Some(Tick::from_units(3, 1)));
|
||||
let mut f = Footage { kind: FootageKind::Video, duration: Tick::from_units(76, 25), ..Footage::default() };
|
||||
trim_to_edits(&mut f, shown);
|
||||
assert_eq!(f.duration, Tick::from_units(75, 25));
|
||||
// Never longer than the reader's duration, and only for video.
|
||||
trim_to_edits(&mut f, Some(Tick::from_units(4, 1)));
|
||||
assert_eq!(f.duration, Tick::from_units(75, 25));
|
||||
assert_eq!(edits_shown(&[edit(1000, -1), edit(3000, 0)], 1000), None);
|
||||
assert_eq!(edits_shown(&[], 1000), None);
|
||||
assert_eq!(edits_shown(&[edit(3000, 0)], 0), None);
|
||||
}
|
||||
|
||||
/// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale.
|
||||
fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec<u32> {
|
||||
let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num);
|
||||
|
||||
@@ -131,6 +131,14 @@ pub trait FxTarget {
|
||||
buf
|
||||
});
|
||||
}
|
||||
/// Give the buffer the pixel-grid phase the layer rectangle's cover would
|
||||
/// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels.
|
||||
fn align(&mut self, size: [f64; 2]) {
|
||||
self.cpu(&mut |mut buf| {
|
||||
buf.align(size);
|
||||
buf
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback).
|
||||
|
||||
@@ -566,14 +566,26 @@ impl<'a> Renderer<'a> {
|
||||
let Some(fx) = layer.effects() else { return };
|
||||
let lt = layer.layer_time(ctx.time);
|
||||
let layer_size = ctx.effect_bounds(layer).0;
|
||||
// #659: an authored layer's buffer spans only its painted content while the stack works
|
||||
// on the layer's whole region (`layer_size`): give the buffer the room a sized source
|
||||
// already has, so tiling or displacement can place pixels outside the content instead
|
||||
// of losing them at its edge. Only when something will actually run, and not for an
|
||||
// adjustment layer, whose buffer is the comp below and already spans it.
|
||||
if !adjustment
|
||||
&& fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) {
|
||||
target.cover(layer_size);
|
||||
// #659 / #1205: an authored layer's buffer spans only its painted content. Effects that
|
||||
// place pixels outside it grow the buffer themselves, by exactly what they need
|
||||
// (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of
|
||||
// the whole layer rectangle on every stack — which paid a comp-sized texture per layer,
|
||||
// per frame in flight, for glow and blur that never use the extra room. The buffer still
|
||||
// takes the cover's pixel-grid phase: a downscaled layer resamples along that grid.
|
||||
// Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still
|
||||
// get the cover: they move content past the painted bounds without growing the buffer.
|
||||
let video = || {
|
||||
fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind {
|
||||
GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
if !adjustment && video().next().is_some() {
|
||||
if video().all(|s| s.skips_cover()) {
|
||||
target.align(layer_size);
|
||||
} else {
|
||||
target.cover(layer_size);
|
||||
}
|
||||
}
|
||||
let mut mask_shapes = masks::shapes(ctx, layer);
|
||||
if origin != [0.0; 2] {
|
||||
|
||||
@@ -510,6 +510,7 @@ pub struct PlacedGlyph {
|
||||
pub fill: [f32; 4],
|
||||
pub stroke: [f32; 4],
|
||||
pub stroke_width: f64,
|
||||
pub join: effectcraft_path::Join,
|
||||
/// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows
|
||||
/// on every side, in character space (0 when not bold).
|
||||
pub bold: f64,
|
||||
@@ -838,6 +839,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
|
||||
join: path_join(doc.line_join),
|
||||
bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 },
|
||||
apply_fill: st.apply_fill,
|
||||
char_index: g.char_index,
|
||||
@@ -853,6 +855,15 @@ pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
|
||||
text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn path_join(j: effectcraft_keyframe::LineJoin) -> effectcraft_path::Join {
|
||||
use effectcraft_keyframe::LineJoin;
|
||||
match j {
|
||||
LineJoin::Miter => effectcraft_path::Join::Miter,
|
||||
LineJoin::Round => effectcraft_path::Join::Round,
|
||||
LineJoin::Bevel => effectcraft_path::Join::Bevel,
|
||||
}
|
||||
}
|
||||
|
||||
/// One paint pass of a glyph: fill (false) or stroke (true).
|
||||
type Pass = (usize, bool);
|
||||
|
||||
@@ -919,7 +930,7 @@ fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect:
|
||||
}
|
||||
outer
|
||||
} else if stroke {
|
||||
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
|
||||
let st = StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() };
|
||||
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
|
||||
} else {
|
||||
grown_coverage(paths, &mm, w, h, bold)
|
||||
|
||||
@@ -598,6 +598,7 @@ fn touching_glyphs_join_without_a_seam() {
|
||||
fill,
|
||||
stroke: [0.0; 4],
|
||||
stroke_width: 0.0,
|
||||
join: effectcraft_path::Join::Miter,
|
||||
bold: 0.0,
|
||||
apply_fill: true,
|
||||
char_index: 0,
|
||||
@@ -668,6 +669,7 @@ fn faux_bold_under_a_non_uniform_scale() {
|
||||
fill: [1.0; 4],
|
||||
stroke: [0.0; 4],
|
||||
stroke_width: 0.0,
|
||||
join: effectcraft_path::Join::Miter,
|
||||
bold: 2.0,
|
||||
apply_fill: true,
|
||||
char_index: 0,
|
||||
|
||||
@@ -572,7 +572,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude
|
||||
groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c)));
|
||||
}
|
||||
if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 {
|
||||
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
|
||||
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() };
|
||||
if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) {
|
||||
let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op];
|
||||
groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c)));
|
||||
|
||||
@@ -1706,7 +1706,8 @@ Object.defineProperty(FootageItem.prototype, "mainSource", {
|
||||
return this.__mainSource;
|
||||
},
|
||||
});
|
||||
FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); };
|
||||
// replace() swaps in one file, even a numbered still (replaceWithSequence brings a run), as in After Effects.
|
||||
FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: false }); };
|
||||
FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) {
|
||||
__call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical });
|
||||
};
|
||||
|
||||
@@ -1087,6 +1087,11 @@ fn footage_proxies_sequences_and_conform_rate() {
|
||||
let paths: Vec<String> =
|
||||
s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect();
|
||||
assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}");
|
||||
// replace() swaps in the one file, even a numbered still, as in After Effects.
|
||||
ok(&mut s, &format!("app.project.item(1).replace(new File('{}'))", p("shot_0002.png")));
|
||||
let kinds: Vec<_> =
|
||||
s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.path.replace('\\', "/"))) } else { None }).collect();
|
||||
assert!(kinds.iter().any(|(k, f)| *k == effectcraft_engine::project::FootageKind::Still && f.ends_with("shot_0002.png")), "{kinds:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -385,7 +385,8 @@ pub fn scan_system() -> usize {
|
||||
}
|
||||
|
||||
/// Scan the system font folders again, adding fonts installed since the last scan (faces already
|
||||
/// known are kept, so font ids stay valid). Returns the number of faces added.
|
||||
/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every
|
||||
/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread.
|
||||
pub fn rescan_system() -> usize {
|
||||
let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
|
||||
@@ -393,6 +394,60 @@ pub fn rescan_system() -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static RESCAN: std::sync::Mutex<Option<std::thread::JoinHandle<usize>>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already
|
||||
/// running, the thread could not be spawned, or this build has no system font folders (wasm).
|
||||
pub fn start_rescan() -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if slot.as_ref().is_some_and(|h| !h.is_finished()) {
|
||||
return false;
|
||||
}
|
||||
match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) {
|
||||
Ok(handle) => {
|
||||
*slot = Some(handle);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a background rescan is still walking the font folders.
|
||||
pub fn rescan_busy() -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().is_some_and(|h| !h.is_finished())
|
||||
}
|
||||
}
|
||||
|
||||
/// Faces added by the latest background rescan, once that thread has finished. `None` while it
|
||||
/// is still running or when nothing was started.
|
||||
pub fn poll_rescan() -> Option<usize> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
None
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished);
|
||||
if finished { slot.take().and_then(|h| h.join().ok()) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the system font scan on a background thread, so the font menus are complete by the
|
||||
/// time someone opens them without the first open waiting for the scan. Native only; on wasm
|
||||
/// (no threads, no font folders) this does nothing.
|
||||
|
||||
@@ -203,6 +203,8 @@ fn system_scan_finds_fonts() {
|
||||
let n = all_faces().iter().filter(|f| f.info.origin == "system").count();
|
||||
eprintln!("system fonts: {n} faces in {:?}", t.elapsed());
|
||||
assert_eq!(scan_system(), 0, "scans once");
|
||||
// A minimum face count on Linux belongs with the native-fonts gate in #1591. A container
|
||||
// that has fontconfig and only a handful of faces must still pass this portable test.
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(n > 20, "{n}");
|
||||
let r = resolve("Helvetica", "Bold");
|
||||
|
||||
@@ -144,6 +144,9 @@ impl EffectcraftApp {
|
||||
// commands handled here use the right catalog already.
|
||||
let language = crate::i18n::language(self);
|
||||
crate::i18n::set_current(language);
|
||||
// #1089: a font-menu rescan runs off the UI thread. Poll it on the logic tick so the
|
||||
// status and the menu update even when the window is not painting.
|
||||
crate::panels::text_panels::poll_font_rescan(self, ctx);
|
||||
if self.styled_language != language {
|
||||
self.want_paint = true;
|
||||
}
|
||||
|
||||
@@ -266,6 +266,8 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin
|
||||
"es" => Some(("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer")),
|
||||
"pt-br" => Some(("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer")),
|
||||
"cs" => Some(("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat")),
|
||||
"fr" => Some(("Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir")),
|
||||
"de" => Some(("Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich")),
|
||||
// A colon before the history's (untranslated) action name: "დაბრუნება: Comp1".
|
||||
"ka" => Some(("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია")),
|
||||
// A colon before the history's (untranslated) action name: "Отменить: Comp1".
|
||||
@@ -836,6 +838,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "行落下"),
|
||||
("anim.addKeyframe", "Add Keyframe", "添加关键帧"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "切换定格关键帧"),
|
||||
("keys.setMuted", "Mute Keyframes", "静音关键帧"),
|
||||
("keys.setMuted", "Unmute Keyframes", "取消静音关键帧"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "关键帧插值..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "关键帧速度..."),
|
||||
("", "Keyframe Assistant", "关键帧辅助"),
|
||||
@@ -1472,6 +1476,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "行落下"),
|
||||
("anim.addKeyframe", "Add Keyframe", "新增關鍵影格"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "切換定格關鍵影格"),
|
||||
("keys.setMuted", "Mute Keyframes", "靜音關鍵影格"),
|
||||
("keys.setMuted", "Unmute Keyframes", "取消靜音關鍵影格"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "關鍵影格插補..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "關鍵影格速度..."),
|
||||
("", "Keyframe Assistant", "關鍵影格輔助"),
|
||||
@@ -2105,6 +2111,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"),
|
||||
("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"),
|
||||
("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"),
|
||||
("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "キーフレーム速度..."),
|
||||
("", "Keyframe Assistant", "キーフレーム補助"),
|
||||
@@ -2741,6 +2749,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Поява рядків згори"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Додати ключовий кадр"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Перемкнути утримання ключового кадру"),
|
||||
("keys.setMuted", "Mute Keyframes", "Заглушити ключові кадри"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Зняти заглушення ключових кадрів"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Інтерполяція ключових кадрів..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Швидкість ключових кадрів..."),
|
||||
("", "Keyframe Assistant", "Помічник ключових кадрів"),
|
||||
@@ -3378,6 +3388,8 @@ const SPANISH: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Entrada de líneas cayendo"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"),
|
||||
("keys.setMuted", "Mute Keyframes", "Silenciar fotogramas clave"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Reactivar fotogramas clave"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."),
|
||||
("", "Keyframe Assistant", "Asistente de fotogramas clave"),
|
||||
@@ -4013,6 +4025,8 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Entrar com queda de linhas"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"),
|
||||
("keys.setMuted", "Mute Keyframes", "Silenciar quadros-chave"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Reativar quadros-chave"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."),
|
||||
("", "Keyframe Assistant", "Assistente de quadro-chave"),
|
||||
@@ -4655,6 +4669,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"),
|
||||
("keys.setMuted", "Mute Keyframes", "Отключить ключевые кадры"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Включить ключевые кадры"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."),
|
||||
("", "Keyframe Assistant", "Помощник по ключевым кадрам"),
|
||||
@@ -5290,6 +5306,8 @@ const FRENCH: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"),
|
||||
("keys.setMuted", "Mute Keyframes", "Désactiver les images clés"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Réactiver les images clés"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."),
|
||||
("", "Keyframe Assistant", "Assistant d'images clés"),
|
||||
@@ -5925,6 +5943,8 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"),
|
||||
("keys.setMuted", "Mute Keyframes", "Keyframes stummschalten"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Keyframes wieder aktivieren"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."),
|
||||
("", "Keyframe Assistant", "Keyframe-Assistent"),
|
||||
@@ -6561,6 +6581,8 @@ const CZECH: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"),
|
||||
("keys.setMuted", "Mute Keyframes", "Ztlumit klíčové snímky"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Obnovit klíčové snímky"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."),
|
||||
("", "Keyframe Assistant", "Pomocník klíčových snímků"),
|
||||
@@ -7192,6 +7214,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"),
|
||||
("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"),
|
||||
("keys.setMuted", "Mute Keyframes", "საკვანძო კადრების დადუმება"),
|
||||
("keys.setMuted", "Unmute Keyframes", "საკვანძო კადრების დადუმების მოხსნა"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."),
|
||||
("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"),
|
||||
@@ -7550,6 +7574,43 @@ mod tests {
|
||||
app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap();
|
||||
assert_eq!(label(&app, "", "Composition"), "Composition");
|
||||
}
|
||||
/// Match System in the browser also picks the Russian, French and German catalogs, in the
|
||||
/// order of the browser's preferences (#1700).
|
||||
#[test]
|
||||
fn browser_preferences_choose_russian_french_and_german() {
|
||||
let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
for (locales, expected) in
|
||||
[(vec!["de-DE", "es-ES"], "de"), (vec!["xx-XX", "fr-CA"], "fr"), (vec!["ru-RU", "cs"], "ru"), (vec!["en-US", "de-DE"], "en"), (vec!["xx-XX"], "en")]
|
||||
{
|
||||
assert_eq!(super::first_supported(None, &tags(&locales), super::web_draws), expected, "{locales:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Edit menu's Undo and Redo name the history's action in the interface language, also in
|
||||
/// the languages added with #1700.
|
||||
#[test]
|
||||
fn undo_and_redo_are_localized_for_russian_french_and_german() {
|
||||
for (language, undo, no_undo, redo, no_redo) in [
|
||||
("ru", "Отменить:", "Нечего отменять", "Повторить:", "Нечего повторять"),
|
||||
("fr", "Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir"),
|
||||
("de", "Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich"),
|
||||
] {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.execute("prefs.set", json!({"key": "general.language", "value": language})).unwrap();
|
||||
let entry_of = |app: &EffectcraftApp, command: &str| {
|
||||
let (_, e) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command).unwrap();
|
||||
entry(app, e, e.label.clone())
|
||||
};
|
||||
assert_eq!(entry_of(&app, "edit.undo"), no_undo, "{language}");
|
||||
assert_eq!(entry_of(&app, "edit.redo"), no_redo, "{language}");
|
||||
app.session.execute("comp.new", json!({"name": "A", "duration": 1})).unwrap();
|
||||
let action = app.session.history.undo.last().map(|u| u.0.clone()).unwrap();
|
||||
assert_eq!(entry_of(&app, "edit.undo"), format!("{undo} {action}"), "{language}");
|
||||
app.session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(entry_of(&app, "edit.redo"), format!("{redo} {action}"), "{language}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_system_uses_the_system_language_where_there_is_a_catalog() {
|
||||
for (locale, language) in [
|
||||
@@ -7676,7 +7737,7 @@ mod tests {
|
||||
app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap();
|
||||
assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять");
|
||||
app.session.execute("prefs.set", json!({"key":"general.language", "value":"de"})).unwrap();
|
||||
assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Can't Undo", "no German undo wording yet: the shown label");
|
||||
assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Rückgängig nicht möglich");
|
||||
app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap();
|
||||
assert_eq!(entry(&app, &new_comp, new_comp.label.clone()), "New Composition...");
|
||||
}
|
||||
|
||||
@@ -434,6 +434,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("No layer selected", "レイヤーが選択されていません"),
|
||||
("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"),
|
||||
("No matching fonts", "一致するフォントはありません"),
|
||||
("Rescan Fonts", "フォントを再スキャン"),
|
||||
("Variable fonts only", "可変フォントのみ"),
|
||||
("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"),
|
||||
("No nested compositions", "ネストされたコンポジションはありません"),
|
||||
@@ -1167,6 +1168,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("No layer selected", "未选择图层"),
|
||||
("No matching effects or presets", "没有匹配的效果或预设"),
|
||||
("No matching fonts", "没有匹配的字体"),
|
||||
("Rescan Fonts", "重新扫描字体"),
|
||||
("Variable fonts only", "仅可变字体"),
|
||||
("No model credits were built into this copy.", "此副本未内置模型致谢。"),
|
||||
("No nested compositions", "没有嵌套合成"),
|
||||
@@ -1951,6 +1953,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("No layer selected", "未選取圖層"),
|
||||
("No matching effects or presets", "沒有匹配的效果或預設集"),
|
||||
("No matching fonts", "沒有符合的字體"),
|
||||
("Rescan Fonts", "重新掃描字型"),
|
||||
("Variable fonts only", "僅可變字型"),
|
||||
("No model credits were built into this copy.", "此副本未內置模型致謝。"),
|
||||
("No nested compositions", "沒有嵌套合成"),
|
||||
@@ -2695,6 +2698,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("No layer selected", "Шар не виділено"),
|
||||
("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"),
|
||||
("No matching fonts", "Не знайдено відповідних шрифтів"),
|
||||
("Rescan Fonts", "Повторно сканувати шрифти"),
|
||||
("Variable fonts only", "Лише змінні шрифти"),
|
||||
("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."),
|
||||
("No nested compositions", "Немає вкладених композицій"),
|
||||
@@ -3230,6 +3234,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("New", "Novo"),
|
||||
("New Comp from Selection", "Nova composição a partir da seleção"),
|
||||
("Replace with Precomposition", "Substituir por pré-composição"),
|
||||
("Rescan Fonts", "Reanalisar fontes"),
|
||||
("used once", "usado uma vez"),
|
||||
("used {} times", "usado {} vezes"),
|
||||
("Compositions that use this item", "Composições que usam este item"),
|
||||
@@ -3991,6 +3996,7 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("New", "Nový"),
|
||||
("New Comp from Selection", "Nová kompozice z výběru"),
|
||||
("Replace with Precomposition", "Nahradit předkompozicí"),
|
||||
("Rescan Fonts", "Znovu nalézt písma"),
|
||||
("used once", "použito jednou"),
|
||||
("used {} times", "použito {}×"),
|
||||
("Compositions that use this item", "Kompozice, které tuto položku používají"),
|
||||
@@ -4575,6 +4581,7 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
("No layer selected", "No hay ninguna capa seleccionada"),
|
||||
("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"),
|
||||
("No matching fonts", "No hay fuentes coincidentes"),
|
||||
("Rescan Fonts", "Volver a analizar fuentes"),
|
||||
("Variable fonts only", "Solo fuentes variables"),
|
||||
("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."),
|
||||
("No nested compositions", "No hay composiciones anidadas"),
|
||||
|
||||
@@ -48,9 +48,21 @@ struct KeyPt {
|
||||
t: f64,
|
||||
v: f64,
|
||||
sel: bool,
|
||||
muted: bool,
|
||||
handles: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// A graph key's fill and edge colours: muted keys stay visible and selectable, dimmed.
|
||||
fn key_colors(t: &Tokens, edge: Color32, k: &KeyPt) -> (Color32, Color32) {
|
||||
let fill = match (k.muted, k.sel) {
|
||||
(true, true) => t.keyframe_selected.gamma_multiply(0.5),
|
||||
(true, false) => Color32::from_gray(0x70),
|
||||
(false, true) => t.keyframe_selected,
|
||||
(false, false) => Color32::from_gray(0xe0),
|
||||
};
|
||||
(fill, if k.muted { edge.gamma_multiply(0.4) } else { edge })
|
||||
}
|
||||
|
||||
struct Curve<'a> {
|
||||
layer: &'a Layer,
|
||||
prop: &'a Property,
|
||||
@@ -153,7 +165,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl
|
||||
let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 };
|
||||
let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) };
|
||||
let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier };
|
||||
if ks && show && (bezier || speed) {
|
||||
if ks && !k.muted && show && (bezier || speed) {
|
||||
handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur });
|
||||
}
|
||||
}
|
||||
@@ -162,7 +174,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl
|
||||
} else {
|
||||
k.value.components().get(d).copied().unwrap_or(0.0)
|
||||
};
|
||||
keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles });
|
||||
keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, muted: k.muted, handles });
|
||||
}
|
||||
out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial });
|
||||
}
|
||||
@@ -522,9 +534,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
}
|
||||
}
|
||||
// The key itself.
|
||||
let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) };
|
||||
let (col, edge) = key_colors(&t, c.color, k);
|
||||
pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col);
|
||||
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside);
|
||||
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, edge), StrokeKind::Outside);
|
||||
let kr = Rect::from_center_size(kp, vec2(11.0, 11.0));
|
||||
let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
|
||||
|
||||
@@ -451,6 +451,22 @@ fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter
|
||||
}
|
||||
}
|
||||
|
||||
/// Elapsed and estimated remaining seconds at UI time `now`. The job reports them as frames
|
||||
/// finish; between reports they count on from the last one, as a clock does, so a slow frame
|
||||
/// doesn't read as a stalled render (#2593).
|
||||
fn live_times(ctx: &egui::Context, pr: &effectcraft_engine::render_queue::JobState, now: f64) -> (f64, Option<f64>) {
|
||||
let report = (pr.current, pr.done, pr.item_elapsed.to_bits());
|
||||
let since = ctx.data_mut(|d| {
|
||||
let seen = d.get_temp_mut_or_insert_with(egui::Id::unique("rq-progress-seen"), || (report, now));
|
||||
if seen.0 != report {
|
||||
*seen = (report, now);
|
||||
}
|
||||
seen.1
|
||||
});
|
||||
let dt = if pr.finished { 0.0 } else { (now - since).max(0.0) };
|
||||
(pr.item_elapsed + dt, pr.remaining.map(|r| (r - dt).max(0.0)))
|
||||
}
|
||||
|
||||
/// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify
|
||||
/// and the progress bar.
|
||||
fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec<Action>) {
|
||||
@@ -461,11 +477,15 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P
|
||||
let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id));
|
||||
let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone());
|
||||
if let Some(pr) = &progress {
|
||||
let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into());
|
||||
let (elapsed, remaining) = live_times(ui.ctx(), pr, ui.input(|i| i.time));
|
||||
if rendering {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let rem = remaining.map(hms).unwrap_or_else(|| "–".into());
|
||||
p.text(
|
||||
pos2(top.max.x - 12.0, top.min.y + 16.0),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)),
|
||||
format!("Elapsed: {} Est. Remain: {rem}", hms(elapsed)),
|
||||
Tokens::ui(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
@@ -903,4 +923,26 @@ mod tests {
|
||||
assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15");
|
||||
assert_eq!(super::hms(3725.4), "1:02:05");
|
||||
}
|
||||
|
||||
/// #2593: between progress reports the times count on from the last report, and a new
|
||||
/// report starts the count again.
|
||||
#[test]
|
||||
fn render_times_tick_between_reports() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut pr = effectcraft_engine::render_queue::JobState {
|
||||
current: Some(1),
|
||||
done: 2,
|
||||
total: 10,
|
||||
item_elapsed: 20.0,
|
||||
remaining: Some(80.0),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(super::live_times(&ctx, &pr, 100.0), (20.0, Some(80.0)));
|
||||
assert_eq!(super::live_times(&ctx, &pr, 103.0), (23.0, Some(77.0)));
|
||||
assert_eq!(super::live_times(&ctx, &pr, 300.0).1, Some(0.0), "never below zero");
|
||||
(pr.done, pr.item_elapsed, pr.remaining) = (3, 31.0, Some(72.0));
|
||||
assert_eq!(super::live_times(&ctx, &pr, 301.0), (31.0, Some(72.0)));
|
||||
pr.finished = true;
|
||||
assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! otherwise they apply to the whole selected text layer.
|
||||
|
||||
use crate::i18n::tr;
|
||||
use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, TextDoc};
|
||||
use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, LineJoin, TextDoc};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use serde_json::json;
|
||||
@@ -191,6 +191,20 @@ fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc:
|
||||
}
|
||||
}
|
||||
|
||||
/// Line Join of the stroke: Miter / Round / Bevel (grid row 5, under the stroke width).
|
||||
fn line_join_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
|
||||
let Grid { x0, y, w, enabled } = g;
|
||||
let bw = (w - 2.0 * 4.0) / 3.0;
|
||||
for (i, (key, label, join)) in
|
||||
[("miter", "Miter", LineJoin::Miter), ("round", "Round", LineJoin::Round), ("bevel", "Bevel", LineJoin::Bevel)].into_iter().enumerate()
|
||||
{
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y + 5.0 * 28.0), vec2(bw, 22.0));
|
||||
if toggle(app, ui, p, r, &format!("character.lineJoin.{key}"), (label, doc.line_join == join, false)) && enabled {
|
||||
actions.push(json!({"lineJoin": key}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Character panel for the text being edited (or the selected text layer): font family and
|
||||
/// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning,
|
||||
/// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes.
|
||||
@@ -233,7 +247,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
numeric_fields(app, ui, &p, grid, &doc, &mut actions);
|
||||
kerning_field(app, ui, &p, grid, &doc, &mut actions);
|
||||
stroke_order_field(app, ui, grid, &doc, &mut actions);
|
||||
y += 5.0 * 28.0 + 8.0;
|
||||
line_join_field(app, ui, &p, grid, &doc, &mut actions);
|
||||
y += 6.0 * 28.0 + 8.0;
|
||||
// Fill / stroke swatches with their enable boxes.
|
||||
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
|
||||
let fx = x0 + i as f32 * (w / 2.0);
|
||||
@@ -639,6 +654,17 @@ struct FontSearch {
|
||||
}
|
||||
|
||||
/// Shared font menu for Character and Properties: search, recent fonts and previews.
|
||||
/// Finish a font rescan started from the font menu and report how many faces it added.
|
||||
/// While the scan is still walking font folders, ask for another frame so the menu updates.
|
||||
pub(crate) fn poll_font_rescan(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
if effectcraft_engine::text::fonts::rescan_busy() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
if let Some(n) = effectcraft_engine::text::fonts::poll_rescan() {
|
||||
app.ui.status = format!("{} +{n}", tr("Rescan Fonts"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option<String> {
|
||||
let state_id = id.with("search-state");
|
||||
app.auto.add(auto, rect, "Font family");
|
||||
@@ -709,6 +735,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check"));
|
||||
ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text);
|
||||
app_ref.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only");
|
||||
// #1089: fonts installed after launch appear without restarting. The button is its own
|
||||
// row: the popup is only 180 px wide, so a control pinned to the filter row covers
|
||||
// "Variable fonts only". The scan itself runs off the UI thread (`poll_font_rescan`).
|
||||
let rescan_h = 22.0;
|
||||
let (resc, _) = ui.allocate_exact_size(vec2(ui.available_width(), rescan_h), egui::Sense::hover());
|
||||
if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() && effectcraft_engine::text::fonts::start_rescan() {
|
||||
app_ref.ui.status = format!("{}…", tr("Rescan Fonts"));
|
||||
}
|
||||
app_ref.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts");
|
||||
let toggled = row.clicked() || boxed.clicked();
|
||||
if toggled {
|
||||
only_variable = !only_variable;
|
||||
|
||||
@@ -1324,6 +1324,20 @@ fn layer_drag_id() -> egui::Id {
|
||||
egui::Id::unique("tl-layer-drag")
|
||||
}
|
||||
|
||||
/// An effect, mask, shape item or text animator being dragged to a new place among its
|
||||
/// siblings (#2210).
|
||||
#[derive(Clone)]
|
||||
struct GroupDrag {
|
||||
comp: ItemId,
|
||||
owner: egui::Id,
|
||||
layer: LayerId,
|
||||
uid: u64,
|
||||
}
|
||||
|
||||
fn group_drag_id() -> egui::Id {
|
||||
egui::Id::unique("tl-group-drag")
|
||||
}
|
||||
|
||||
/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time
|
||||
/// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset
|
||||
/// (Ctrl/Cmd+Alt), how many layers it staggers.
|
||||
@@ -1522,7 +1536,7 @@ fn shift_range(ctx: &egui::Context, cid: ItemId, comp: &Comp, rows: &[Row], sele
|
||||
/// Where layers dropped at height `py` land among the visible layer rows: above the first row
|
||||
/// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that
|
||||
/// and the y of the line that shows it (`top` without rows).
|
||||
fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option<usize>, f32) {
|
||||
fn insertion<T>(layer_rows: &[(Rect, T)], py: f32, top: f32) -> (Option<usize>, f32) {
|
||||
let at = layer_rows.iter().position(|(r, _)| py < r.center().y);
|
||||
let y = match at {
|
||||
Some(i) => layer_rows.get(i).map_or(top, |(r, _)| r.min.y),
|
||||
@@ -1885,6 +1899,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
alt_scale_and_graph(app, ui, g, &ectx);
|
||||
graph_empty_area(ui, g);
|
||||
layer_reorder_drag(ui, g);
|
||||
group_reorder_drag(ui, g);
|
||||
quick_offset_follow(g);
|
||||
key_drag_follow(app, g);
|
||||
key_box_select(app, ui, g);
|
||||
@@ -3559,6 +3574,7 @@ fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
|
||||
|
||||
/// Group row click and double-click.
|
||||
fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
|
||||
let cid = g.tl.cid;
|
||||
let cw = &g.tl.cw;
|
||||
let actions = &mut g.tl.actions;
|
||||
let ui_actions = &mut g.ui_actions;
|
||||
@@ -3567,7 +3583,12 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc:
|
||||
let indent = rc.indent;
|
||||
let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return };
|
||||
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
|
||||
let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click());
|
||||
// Effects, masks, shape items and animators drag to a new place among their siblings.
|
||||
let movable = !layer.switches.locked && layer.props.find_group(*uid).is_some_and(PropGroup::is_instance);
|
||||
let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), if movable { Sense::click_and_drag() } else { Sense::click() });
|
||||
if movable && gr.drag_started_by(egui::PointerButton::Primary) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(group_drag_id(), GroupDrag { comp: cid, owner: gr.id, layer: layer.id, uid: *uid }));
|
||||
}
|
||||
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
|
||||
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
|
||||
@@ -4090,6 +4111,7 @@ fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop:
|
||||
("Keyframe Interpolation…", "keys.interpolation", json!({})),
|
||||
("Keyframe Velocity…", "keys.velocity", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
(if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})),
|
||||
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
|
||||
("-", "", json!({})),
|
||||
("Easy Ease", "keys.easyEase", json!({})),
|
||||
@@ -4182,6 +4204,8 @@ fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe
|
||||
None if ks => t.keyframe_selected,
|
||||
None => t.keyframe,
|
||||
};
|
||||
// Muted keys stay visible and editable but no longer drive the value.
|
||||
let kcol = if k.muted { kcol.gamma_multiply(0.35) } else { kcol };
|
||||
if k.roving {
|
||||
// Roving keys: a small dot (their time follows their neighbours).
|
||||
gp.circle_filled(c, 2.5, kcol);
|
||||
@@ -4481,6 +4505,50 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) {
|
||||
// Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
|
||||
}
|
||||
|
||||
/// Group reorder drag (#2210): a line where the dragged effect, mask or shape item lands among
|
||||
/// its siblings; the drop moves it there (`prop.moveGroup`). Escape cancels.
|
||||
fn group_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) {
|
||||
let ctx = g.tl.ctx.clone();
|
||||
let comp = g.tl.comp.clone();
|
||||
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag>(group_drag_id())) else { return };
|
||||
let (down, released, escape) =
|
||||
ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape)));
|
||||
let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released;
|
||||
let parent = comp.layer(drag.layer).and_then(|l| l.props.parent_of(drag.uid));
|
||||
let valid = ui.is_enabled() && drag.comp == g.tl.cid && parent.is_some() && !escape && (dropped || (down && ctx.dragged_id() == Some(drag.owner)));
|
||||
if dropped || !valid {
|
||||
ctx.data_mut(|d| d.remove::<GroupDrag>(group_drag_id()));
|
||||
}
|
||||
let Some(parent) = parent.filter(|_| valid) else { return };
|
||||
let siblings: Vec<u64> = parent.children.iter().map(Node::uid).collect();
|
||||
let rows: Vec<(Rect, u64)> = g
|
||||
.hit_rows
|
||||
.iter()
|
||||
.filter_map(|(r, row)| match &row.kind {
|
||||
RowKind::Group { uid, .. } if row.layer == drag.layer && siblings.contains(uid) => Some((*r, *uid)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y) else { return };
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (at, line_y) = insertion(&rows, py, g.tl.rows_rect.min.y);
|
||||
if !dropped {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
g.tl.p.with_clip_rect(g.tl.rows_rect).line_segment([pos2(g.tl.rect.min.x, line_y), pos2(g.tl.graph_x0, line_y)], Stroke::new(2.0, g.tl.t.accent));
|
||||
return;
|
||||
}
|
||||
// The sibling the drop lands before (or after, below the last visible one).
|
||||
let Some(&(_, anchor)) = at.map_or(rows.last(), |i| rows.get(i)) else { return };
|
||||
let from = siblings.iter().position(|u| *u == drag.uid);
|
||||
let rest: Vec<u64> = siblings.iter().copied().filter(|u| *u != drag.uid).collect();
|
||||
let to = if anchor == drag.uid { from } else { rest.iter().position(|u| *u == anchor).map(|i| i + usize::from(at.is_none())) };
|
||||
if let Some(to) = to.filter(|to| Some(*to) != from) {
|
||||
g.tl.actions.push(("prop.moveGroup".into(), json!({"layer": drag.layer.0, "prop": drag.uid, "index": to + 1})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick Offset of layer bars follows the pointer.
|
||||
fn quick_offset_follow(g: &mut Rw) {
|
||||
let t = g.tl.t;
|
||||
|
||||
@@ -1264,6 +1264,39 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx,
|
||||
v.puppet_layers = puppet_layers;
|
||||
}
|
||||
|
||||
/// Auto-pan (#999): a mask vertex, tangent or Pen drag held at (or past) the edge of the view
|
||||
/// pans it, as in After Effects, so the point moves on past what was visible. Returns the map
|
||||
/// of the panned view.
|
||||
fn edge_pan(app: &mut EffectcraftApp, ctx: &egui::Context, g: &Gesture, map: ViewerMap, pos: Pos2) -> ViewerMap {
|
||||
if !matches!(g, Gesture::Vertices { .. } | Gesture::Tangent { .. } | Gesture::Pen { .. }) {
|
||||
return map;
|
||||
}
|
||||
let d = edge_pan_speed(map.area, pos) * ctx.input(|i| i.stable_dt).min(0.1);
|
||||
if d == egui::Vec2::ZERO {
|
||||
return map;
|
||||
}
|
||||
app.ui.viewer.pan = [app.ui.viewer.pan[0] + d.x, app.ui.viewer.pan[1] + d.y];
|
||||
ctx.request_repaint();
|
||||
ViewerMap { origin: map.origin + d, ..map }
|
||||
}
|
||||
|
||||
/// How fast (points per second, per axis) the view pans under a drag at `pos`: within 24 points
|
||||
/// of an edge of `area`, or past it, toward that edge and faster the further out.
|
||||
fn edge_pan_speed(area: Rect, pos: Pos2) -> egui::Vec2 {
|
||||
const ZONE: f32 = 24.0;
|
||||
let axis = |p: f32, lo: f32, hi: f32| {
|
||||
let depth = if p < lo + ZONE {
|
||||
lo + ZONE - p
|
||||
} else if p > hi - ZONE {
|
||||
hi - ZONE - p
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(depth / ZONE).clamp(-3.0, 3.0) * 400.0
|
||||
};
|
||||
vec2(axis(pos.x, area.min.x, area.max.x), axis(pos.y, area.min.y, area.max.y))
|
||||
}
|
||||
|
||||
fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
|
||||
let t = v.t;
|
||||
let zoom = v.zoom;
|
||||
@@ -1274,6 +1307,7 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp:
|
||||
let painter = v.painter.clone();
|
||||
let gesture: Option<Gesture> = ui.data(|d| d.get_temp(gid));
|
||||
if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) {
|
||||
let map = edge_pan(app, &ctx, &g, map, pos);
|
||||
let cpt = map.to_comp(pos);
|
||||
let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::<u64>(egui::Id::unique("viewer-drag-n")).unwrap_or(0)));
|
||||
match g {
|
||||
|
||||
@@ -15,7 +15,7 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
|
||||
app.auto.add("viewer.empty.newComp", b1, "New Composition");
|
||||
let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0));
|
||||
if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() {
|
||||
let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({}));
|
||||
let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({"newComp": true}));
|
||||
}
|
||||
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
|
||||
// A Project item dropped on the empty viewer creates the first composition,
|
||||
|
||||
@@ -139,3 +139,15 @@ fn gizmo_segments_clip_at_the_near_plane() {
|
||||
assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}");
|
||||
assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none());
|
||||
}
|
||||
|
||||
/// #999: a vertex drag pans the view only near or past the edge, toward it, faster further out.
|
||||
#[test]
|
||||
fn edge_pan_speed_moves_toward_the_edge() {
|
||||
let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(900.0, 650.0));
|
||||
assert_eq!(edge_pan_speed(area, pos2(500.0, 350.0)), egui::Vec2::ZERO);
|
||||
let left = edge_pan_speed(area, pos2(105.0, 350.0));
|
||||
assert!(left.x > 0.0 && left.y == 0.0, "near the left edge the view moves right to show more: {left:?}");
|
||||
assert!(edge_pan_speed(area, pos2(20.0, 350.0)).x > left.x, "faster past the edge");
|
||||
let corner = edge_pan_speed(area, pos2(950.0, 700.0));
|
||||
assert!(corner.x < 0.0 && corner.y < 0.0, "{corner:?}");
|
||||
}
|
||||
|
||||
@@ -360,31 +360,36 @@ pub(crate) fn motion_path(
|
||||
}
|
||||
let v = k.value.as_vec3();
|
||||
let s = to_scr(v);
|
||||
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
|
||||
// ends), to drag it into a curve; Linear keys (no tangents) show none.
|
||||
let (tin, tout) = if k.spatial_auto {
|
||||
effectcraft_engine::keyframe::spatial_handles(&pos.keys, i)
|
||||
} else {
|
||||
effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i)
|
||||
};
|
||||
for (out, tg) in [(false, tin), (true, tout)] {
|
||||
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
|
||||
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
|
||||
continue;
|
||||
let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time });
|
||||
// A muted position key is off the motion path: no tangents, and a dimmed point.
|
||||
let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
|
||||
if !k.muted {
|
||||
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
|
||||
// ends), to drag it into a curve; Linear keys (no tangents) show none.
|
||||
let (tin, tout) = if k.spatial_auto {
|
||||
effectcraft_engine::keyframe::spatial_handles(&pos.keys, i)
|
||||
} else {
|
||||
effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i)
|
||||
};
|
||||
for (out, tg) in [(false, tin), (true, tout)] {
|
||||
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
|
||||
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
|
||||
continue;
|
||||
}
|
||||
let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, kc));
|
||||
painter.circle_filled(h, 3.0, kc);
|
||||
let side = if out { "out" } else { "in" };
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
|
||||
}
|
||||
let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, col));
|
||||
painter.circle_filled(h, 3.0, col);
|
||||
let side = if out { "out" } else { "in" };
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
|
||||
}
|
||||
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
|
||||
if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) {
|
||||
painter.rect_filled(r, 0.0, col);
|
||||
if selected {
|
||||
painter.rect_filled(r, 0.0, kc);
|
||||
} else {
|
||||
painter.rect_filled(r, 0.0, Color32::from_black_alpha(140));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });
|
||||
|
||||
@@ -632,6 +632,27 @@ fn import_dialog_offers_the_image_sequence_option() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #2573: the empty Composition viewer's New Composition From Footage imports the picked file
|
||||
/// (through the Import Image Sequence question a numbered still asks) and makes a composition
|
||||
/// of it, as in After Effects.
|
||||
#[test]
|
||||
fn new_composition_from_footage_makes_a_comp_of_the_picked_file() {
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default()));
|
||||
h.run_steps(3);
|
||||
let dir = frames("fromfootage", 2);
|
||||
let pick = dir.join("frame_0002.png").to_string_lossy().to_string();
|
||||
h.state_mut().session.importer = Some(Arc::new(Stills));
|
||||
h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()]));
|
||||
click_id(&mut h, "viewer.empty.import");
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form));
|
||||
click_id(&mut h, "form.ok");
|
||||
step_until(&mut h, |h| h.state().session.active_comp().is_some());
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
assert_eq!((comp.width, comp.height), (64, 32));
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["frame_0002.png"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret
|
||||
/// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in
|
||||
/// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one
|
||||
|
||||
@@ -256,3 +256,24 @@ fn font_menu_grows_back_after_an_empty_search() {
|
||||
click(&mut h, "character.font.option.JetBrains Mono");
|
||||
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono", "a click on a row picks the font");
|
||||
}
|
||||
|
||||
/// #1089: the font menu can re-run the system scan without restarting the app, and the press
|
||||
/// does not also flip the "Variable fonts only" filter.
|
||||
#[test]
|
||||
fn font_menu_rescan_button_adds_fonts_installed_later() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
|
||||
s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::Character);
|
||||
h.run_steps(3);
|
||||
click(&mut h, "character.font");
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan");
|
||||
click(&mut h, "character.font.rescan");
|
||||
assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status);
|
||||
// The popup stays open, the variable filter did not flip, and the list still has the bundled families.
|
||||
assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open");
|
||||
let rows = h.state().auto.query("character.font.option.");
|
||||
assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
@@ -1734,3 +1734,19 @@ fn same_caret_application_text_replacement_rejects_the_previous_context_fence()
|
||||
h.run_steps(1);
|
||||
assert_eq!(edited(&h).1, "Aがbcd");
|
||||
}
|
||||
|
||||
/// #2077: the Character panel's Line Join buttons set the stroke's corner style (Miter for new text).
|
||||
#[test]
|
||||
fn character_panel_line_join_buttons_set_the_join() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
let lid = s.execute("layer.newText", json!({"text": "Join", "size": 80, "applyStroke": true, "strokeWidth": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = harness(EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::Character);
|
||||
h.run_steps(3);
|
||||
let join = |h: &Harness<'_, EffectcraftApp>| layer_doc(&h.state().session, LayerId(lid)).unwrap().line_join;
|
||||
assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Miter);
|
||||
let bevel = rect(&h, "character.lineJoin.bevel").center();
|
||||
click(&mut h, bevel, Modifiers::NONE);
|
||||
assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Bevel);
|
||||
}
|
||||
|
||||
@@ -396,3 +396,55 @@ fn ctrl_v_pastes_svg_markup_as_a_shape_layer() {
|
||||
assert_eq!(c.layers.len(), 4);
|
||||
assert_eq!(c.layers[0].name, "Oval 2");
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, cancel: bool) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
if cancel {
|
||||
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// #2210: an effect's row drags to a new place in the layer's effect stack, as in After Effects
|
||||
/// (not a marquee); Escape cancels the drag.
|
||||
#[test]
|
||||
fn effects_drag_to_a_new_place_in_the_timeline() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for fx in ["Gaussian Blur", "Invert", "Tint"] {
|
||||
s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap();
|
||||
}
|
||||
let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone();
|
||||
let uids: Vec<u64> = effects.groups().map(|g| g.uid).collect();
|
||||
let mut h = harness(s);
|
||||
h.state_mut().ui.timeline.open_groups.insert(effects.uid);
|
||||
open_layer(&mut h, l);
|
||||
let names = |h: &Harness<'_, EffectcraftApp>| -> Vec<String> {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().groups().map(|g| g.name.clone()).collect()
|
||||
};
|
||||
let row = |h: &Harness<'_, EffectcraftApp>, uid: u64| {
|
||||
let e = h.state().auto.find(&format!("timeline.group.{uid}.name")).unwrap_or_else(|| panic!("no row for {uid}"));
|
||||
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
|
||||
};
|
||||
// Tint above Gaussian Blur.
|
||||
let (tint, blur) = (row(&h, uids[2]), row(&h, uids[0]));
|
||||
drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), true);
|
||||
assert_eq!(names(&h), ["Gaussian Blur", "Invert", "Tint"], "Escape cancels");
|
||||
drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), false);
|
||||
assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"]);
|
||||
// Gaussian Blur (now second) below Invert, the last.
|
||||
let (blur, invert) = (row(&h, uids[0]), row(&h, uids[1]));
|
||||
drag(&mut h, blur.center(), pos2(invert.center().x, invert.max.y - 2.0), false);
|
||||
assert_eq!(names(&h), ["Tint", "Invert", "Gaussian Blur"]);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop");
|
||||
}
|
||||
|
||||
@@ -831,6 +831,48 @@ fn shift_drag_keeps_a_mask_vertex_to_one_axis() {
|
||||
assert!(free[0] > shifted[0] && free[1] > shifted[1], "without Shift both axes move: {shifted:?} -> {free:?}");
|
||||
}
|
||||
|
||||
/// #999: a mask vertex dragged against the edge of the Composition panel pans the view, and the
|
||||
/// vertex keeps moving while the pointer is held there, as in After Effects.
|
||||
#[test]
|
||||
fn a_vertex_held_at_the_viewer_edge_pans_the_view() {
|
||||
let mut h = harness();
|
||||
let plate = layer_id(&h, "Plate");
|
||||
let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]];
|
||||
let mask = {
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.select", json!({"layers": [plate]})).unwrap();
|
||||
let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap();
|
||||
s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap();
|
||||
m
|
||||
};
|
||||
h.run_steps(2);
|
||||
let pan0 = h.state().ui.viewer.pan;
|
||||
let from = rect(&h, &format!("viewer.mask.{mask}.vertex.0")).center();
|
||||
let edge = pos2(2.0, from.y);
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for i in 1..=6 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (edge - from) * (i as f32 / 6.0)));
|
||||
h.step();
|
||||
}
|
||||
let reached = mask_path(&h, "Plate").vertices[0];
|
||||
for _ in 0..20 {
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: edge, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
let pan = h.state().ui.viewer.pan;
|
||||
assert!(pan[0] > pan0[0] && pan[1] == pan0[1], "the view panned right to show more on the left: {pan0:?} -> {pan:?}");
|
||||
let after = mask_path(&h, "Plate").vertices[0];
|
||||
assert!(after[0] < reached[0], "the vertex kept moving left with the view: {reached:?} -> {after:?}");
|
||||
// Back inside the view, nothing pans.
|
||||
let still = h.state().ui.viewer.pan;
|
||||
let mid = rect(&h, "viewer.comp").center();
|
||||
drag(&mut h, mid, mid + vec2(5.0, 0.0));
|
||||
assert_eq!(h.state().ui.viewer.pan, still);
|
||||
}
|
||||
|
||||
/// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable.
|
||||
#[test]
|
||||
fn hidden_masked_layer_keeps_its_mask_path_editable() {
|
||||
|
||||
Reference in New Issue
Block a user