Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	crates/ui-egui/src/panels/viewer/frame.rs
This commit is contained in:
bflatastic
2026-10-30 07:06:05 +07:00
91 changed files with 2912 additions and 220 deletions
+5
View File
@@ -120,6 +120,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain
that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it
starts off, so existing shapes behave as before (#2489).
- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or
`layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and
rotation of the paths above it by random amounts that change over time, up to the Transform
values it holds. The copies of a Repeater above it wiggle independently as far as its
Correlation allows; with the Repeater below it, they wiggle together.
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
path into an editable Path 1 with the same geometry, as After Effects does; it used to
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
+2
View File
@@ -272,6 +272,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
}
let mut session = session();
session.load_settings();
// Scripts in Scripts/Startup run before a project opens.
let _ = session.execute("file.runStartupScripts", json!({}));
let recovery = session.begin_recovery();
// `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's
// `startup` says (default: the last visit's project, the demo on a first visit).
+5
View File
@@ -263,6 +263,9 @@ fn main() -> eframe::Result {
// Fit the window to its monitor, or put it back where it was (still hidden).
let saved_window = session.config.as_deref().and_then(window::load);
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
// Scripts in Scripts/Startup run before a project opens (a failing one is reported in
// a toast).
let _ = session.execute("file.runStartupScripts", json!({}));
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
let project = files.iter().find(|f| is_project_file(f)).cloned();
if let Some(p) = project {
@@ -495,6 +498,8 @@ impl eframe::App for Desktop {
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
// Scripts in Scripts/Shutdown run with the project closed.
let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
+4 -4
View File
@@ -30,10 +30,9 @@ pub struct Confinement {
write: Vec<PathBuf>,
}
/// Commands that run code, act outside the project or open other applications.
/// Commands that run code, act outside the project or open other applications, besides the
/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]).
const REFUSED: &[&str] = &[
"script.run",
"file.runScript",
"file.installScript",
"file.installScriptUIPanel",
"file.uninstallScript",
@@ -167,7 +166,7 @@ impl Confinement {
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
if REFUSED.contains(&id) {
if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) {
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
}
// Scripts can't grant themselves the access the user withheld
@@ -397,6 +396,7 @@ mod tests {
let classified = keys_of(READS, id).is_some()
|| keys_of(WRITES, id).is_some()
|| REFUSED.contains(&id)
|| effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id)
|| IMPLICIT_WRITES.contains(&id)
|| id.starts_with("renderQueue.")
|| id == "render.addOutputModule"
+12
View File
@@ -1412,6 +1412,18 @@ fn automation_roots_confine_file_access() {
// Scripts can read and write anything: refused.
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
for (id, params) in [
("file.runStartupScripts", json!({})),
("file.runShutdownScripts", json!({})),
// ScriptUI panels run their script when they open.
("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
("window.restoreScriptPanels", json!({})),
] {
let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params}));
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]}));
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
}
// Writes to the files the agent named: none outside.
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");
let _ = std::fs::remove_dir_all(&base);
+3
View File
@@ -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);
+69
View File
@@ -312,6 +312,19 @@ impl Buf {
self.offset[0] += l as f64;
self.offset[1] += t as f64;
}
/// 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) {
@@ -377,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
@@ -555,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 }
}
@@ -1583,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());
}
}
+6
View File
@@ -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);
+15
View File
@@ -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);
+1 -1
View File
@@ -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;
+85 -10
View File
@@ -100,17 +100,11 @@ impl Cineon {
fn knee(&self) -> f32 {
1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99)
}
/// Normalised code value (code / 1023) → linear.
/// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma`
/// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings
/// also uses with `1 / gamma`).
pub fn log_to_lin(&self, v: f32) -> f32 {
let off = self.offset();
let code = v * 1023.0;
let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01));
let k = self.knee();
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k + w * (1.0 - (-(lin - k) / w).exp());
}
let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff);
self.ib + (self.iw - self.ib) * lin
}
/// Linear → normalised code value.
@@ -495,6 +489,87 @@ mod tests {
assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);
}
/// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same
/// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times
/// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give
/// linear light (code 400 is 0.10251).
#[test]
fn cineon_converter_matches_interpret_footage() {
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] {
let mut img = Image::new(codes.len() as u32, 1);
for (x, code) in codes.iter().enumerate() {
let v = code / 1023.0;
img.set(x as u32, 0, [v, v, v, 1.0]);
}
let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))];
let out = run("ec.utility.cineon", &vals, img);
let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() };
for (x, code) in codes.iter().enumerate() {
let footage = settings.convert(code / 1023.0);
let effect = out.img.data[x][0];
assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}");
}
}
let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() };
assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4);
}
/// The Cineon Converter gives the same values as before Cineon Settings shared its
/// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three
/// conversion types.
#[test]
fn cineon_converter_output_is_unchanged() {
fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
let off = 10f32.powf((black - white) * 0.002 / 0.6);
let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
lin = lin.max(0.0).powf(1.7 / gamma.max(0.01));
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k + w * (1.0 - (-(lin - k) / w).exp());
}
lin
}
fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
let off = 10f32.powf((black - white) * 0.002 / 0.6);
let mut lin = v;
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln();
}
lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7);
(white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0
}
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
for gamma in [None, Some(2.4)] {
for kind in 0..3 {
let mut img = Image::new(codes.len() as u32, 1);
for (x, code) in codes.iter().enumerate() {
let v = code / 1023.0;
img.set(x as u32, 0, [v, v, v, 1.0]);
}
let mut vals = vec![("conversionType", Value::Enum(kind))];
if let Some(g) = gamma {
vals.push(("gamma", num(g)));
}
let out = run("ec.utility.cineon", &vals, img);
let g = gamma.unwrap_or(1.7) as f32;
for (x, code) in codes.iter().enumerate() {
let v = code / 1023.0;
let want = match kind {
0 => lin_to_log(v, g, 20.0, 95.0, 685.0),
1 => log_to_lin(v, g, 20.0, 95.0, 685.0),
_ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0),
};
let got = out.img.data[x][0];
assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}");
}
}
}
}
#[test]
fn hdr_compression_behaviour() {
let mut img = Image::new(2, 1);
+16 -16
View File
@@ -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),
+3 -2
View File
@@ -197,7 +197,8 @@ pub(crate) struct Work {
}
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
/// Footage at its native size is used straight from the footage source (no copy).
/// Footage at its native size is used straight from the footage source (no copy unless its
/// Cineon Settings convert it).
pub(crate) fn source_frame(
r: &Renderer,
project: &Project,
@@ -215,7 +216,7 @@ pub(crate) fn source_frame(
&& img.width == f.width
&& img.height == f.height
{
return Some((img, [0.0; 2]));
return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2]));
}
let buf = r.layer_source(&ctx, layer)?;
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
+4 -3
View File
@@ -9,8 +9,9 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{EngineError, Result, Session};
/// Commands that can't run inside a batch (they replace the project or nest batches).
const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
/// Commands that can't run inside a batch (they nest batches), besides the commands that run
/// scripts ([`super::scripts::RUNS_SCRIPTS`]).
const NOT_IN_BATCH: &[&str] = &["engine.batch"];
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
fn reference(s: &str, results: &[Value]) -> Option<Result<Value>> {
@@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
.or_else(|| step.get("id"))
.and_then(Value::as_str)
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
if NOT_IN_BATCH.contains(&id) {
if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) {
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
}
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
+56 -17
View File
@@ -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
),
+163 -5
View File
@@ -5,7 +5,7 @@
use std::collections::{BTreeMap, BTreeSet};
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
};
if id == "file.runScript" || id == "script.run" {
if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) {
return Err(bad("file.runScript", "scripts can't run scripts"));
}
let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) };
@@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool {
}
/// A script error becomes a toast and a command error.
fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
@@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result<Va
report_js(s, name, out)
}
/// Run the code of the script file at `path` (its `#include`s and `$.fileName` use that path),
/// reported under its file name `name` (Startup and Shutdown scripts).
pub(crate) fn run_js_file(s: &mut Session, code: &str, path: &str, name: &str) -> Result<Value> {
let out = run_js(s, code, path, false)?;
report_js(s, name, out)
}
/// Run JavaScript through the session's scripting engine.
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
@@ -594,6 +601,131 @@ pub(crate) struct Interpretation {
preserve_rgb: Option<bool>,
/// Start Timecode.
start: Option<StartTimecode>,
/// Cineon Settings (DPX and Cineon footage).
cineon: CineonEdit,
}
/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional).
#[derive(Clone, Debug, Default)]
struct CineonEdit {
/// Logarithmic Conversion.
log: Option<bool>,
units: Option<CineonUnits>,
/// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`,
/// else the footage's own Units).
point_units: Option<CineonUnits>,
converted_black: Option<f64>,
converted_white: Option<f64>,
/// 10 Bit Black and White Points.
black: Option<f64>,
white: Option<f64>,
gamma: Option<f64>,
rolloff: Option<f64>,
}
impl CineonEdit {
fn parse(p: &Value, cmd: &str) -> Result<CineonEdit> {
let units = |key: &str| -> Result<Option<CineonUnits>> {
match str_p(p, key) {
None => Ok(None),
Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))),
}
};
let num = |key: &str, lo: f64, hi: f64| -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && (lo..=hi).contains(x))
.map(Some)
.ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))),
}
};
let units_now = units("cineonUnits")?;
Ok(CineonEdit {
log: b_p(p, "logConversion"),
units: units_now,
point_units: units("pointUnits")?.or(units_now),
// Range-checked once their units are known (`settings_for`).
converted_black: num("convertedBlack", f64::MIN, f64::MAX)?,
converted_white: num("convertedWhite", f64::MIN, f64::MAX)?,
black: num("tenBitBlack", 0.0, 1023.0)?,
white: num("tenBitWhite", 0.0, 1023.0)?,
gamma: num("currentGamma", 0.1, 10.0)?,
rolloff: num("highlightRolloff", 0.0, 100.0)?,
})
}
fn is_empty(&self) -> bool {
self.log.is_none()
&& self.units.is_none()
&& self.converted_black.is_none()
&& self.converted_white.is_none()
&& self.black.is_none()
&& self.white.is_none()
&& self.gamma.is_none()
&& self.rolloff.is_none()
}
/// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon
/// parameters, or `f` is not a DPX or Cineon file).
fn settings_for(&self, f: &Footage, cmd: &str) -> Result<Option<CineonSettings>> {
if self.is_empty() || !f.is_cineon_file() {
return Ok(None);
}
let mut c = f.cineon;
let units = self.point_units.unwrap_or(c.units);
let point = |v: f64| -> Result<f32> {
let ten = units.to_ten_bit(v);
let max = units.from_ten_bit(f64::from(CineonSettings::MAX));
if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) {
Ok(ten as f32)
} else {
Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label())))
}
};
if let Some(v) = self.converted_black {
c.converted_black = point(v)?;
}
if let Some(v) = self.converted_white {
c.converted_white = point(v)?;
}
if let Some(v) = self.log {
c.log_conversion = v;
}
if let Some(u) = self.units {
c.units = u;
}
if let Some(v) = self.black {
c.black = v as f32;
}
if let Some(v) = self.white {
c.white = v as f32;
}
if let Some(v) = self.gamma {
c.gamma = v as f32;
}
if let Some(v) = self.rolloff {
c.rolloff = v as f32;
}
if c.black >= c.white {
return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point"));
}
Ok(Some(c))
}
}
/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to).
fn cineon_json(f: &Footage) -> Value {
if !f.is_cineon_file() {
return Value::Null;
}
let c = &f.cineon;
json!({
"logConversion": c.log_conversion, "units": c.units.id(),
"convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)),
"tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff,
})
}
/// Interpret Footage ▸ Start Timecode.
@@ -687,9 +819,23 @@ impl Interpretation {
v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?,
)),
};
it.cineon = CineonEdit::parse(p, cmd)?;
Ok(it)
}
/// Check the Cineon Settings this interpretation gives each of `footage`: an error names a
/// value out of range, or Cineon parameters when none of the footage is DPX or Cineon.
pub(crate) fn check<'a>(&self, footage: impl IntoIterator<Item = &'a Footage>, cmd: &str) -> Result<()> {
if self.cineon.is_empty() {
return Ok(());
}
let mut any = false;
for f in footage {
any |= self.cineon.settings_for(f, cmd)?.is_some();
}
if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) }
}
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
let before = f.frame_rate;
self.apply_settings(f, guessed);
@@ -749,6 +895,10 @@ impl Interpretation {
if let Some(v) = self.preserve_rgb {
f.preserve_rgb = v;
}
// Checked before the edit (`Interpretation::check`).
if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") {
f.cineon = c;
}
// After the frame rate: timecode is read at the footage's rate.
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
f.start_timecode = start;
@@ -795,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("file.interpretFootage", "select footage"));
}
let it = Interpretation::parse(p, "file.interpretFootage")?;
let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) {
Some(ItemKind::Footage(f)) => Some(f),
_ => None,
});
it.check(footage, "file.interpretFootage")?;
let guesses: Vec<Option<(AlphaMode, [f32; 3])>> = items
.iter()
.map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {
@@ -830,7 +985,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
"linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
"frames": f.sequence_frames(),
"frames": f.sequence_frames(), "cineon": cineon_json(f),
})),
_ => None,
})
@@ -861,6 +1016,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
f.linear_light = src.linear_light;
f.preserve_rgb = src.preserve_rgb;
f.color_profile = src.color_profile;
if f.is_cineon_file() && src.is_cineon_file() {
f.cineon = src.cineon;
}
if src.native_rate.is_some() {
if f.native_rate.is_none() {
f.native_rate = Some(f.frame_rate);
@@ -1090,7 +1248,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}",
has_footage_selection,
interpret
),
+6
View File
@@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time::
}
}
/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings
/// applied.
pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f))
}
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
let (_, fr) = source_timing(s, item);
if let Some(t) = f_p(p, "time") {
+1 -1
View File
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
has_layers,
add_shape_item
),
+1 -1
View File
@@ -360,7 +360,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
const SHAPE_ITEMS: &[&str] = &[
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
"wiggle", "merge", "front", "bevel", "side", "back",
"wiggle", "wiggler", "merge", "front", "bevel", "side", "back",
];
/// Selected shape items (contents entries) of shape layers: (layer, uid).
+10
View File
@@ -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...",
+2 -1
View File
@@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "the selected items have no proxy"));
}
let it = super::file_more::Interpretation::parse(p, C)?;
it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?;
let guesses: Vec<_> = items
.iter()
.map(|i| {
@@ -229,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Proxy...",
["File", "Interpret Footage"],
None,
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}",
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}",
has_proxy,
interpret_proxy
),
+174
View File
@@ -7,6 +7,9 @@
//! panels appear at the bottom of the Window menu and open as dockable panels
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by
//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft
//! quits, after the project is closed (`file.runShutdownScripts`).
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
@@ -23,6 +26,18 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
/// The settings-store folders of installed scripts.
pub const SCRIPTS_DIR: &str = "Scripts";
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
/// The settings-store folders whose scripts run at launch and as EffectCraft quits.
pub const STARTUP_DIR: &str = "Scripts/Startup";
pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown";
/// Commands that run script files or script code (a ScriptUI panel runs its script when it
/// opens). Scripts and batches can't run them, and confined automation sessions refuse them.
pub const RUNS_SCRIPTS: &[&str] =
&["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"];
/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and
/// `$.evalFile` allow.
const MAX_SCRIPT_BYTES: usize = 16 << 20;
/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled.
const MAX_SHUTDOWN_DIALOGS: usize = 64;
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
const PANELS_FOLDER: &str = "ScriptUI Panels";
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
@@ -221,6 +236,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {
let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?;
let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR };
cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?;
s.scripts_changed();
// After Effects asks for a restart; our menus pick it up at once.
s.toast(if panel {
format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu")
@@ -256,6 +272,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
removed = true;
}
}
s.scripts_changed();
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
}
@@ -337,6 +354,85 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"opened": opened, "failed": failed}))
}
/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case
/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is
/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a
/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs
/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as
/// if canceled (`show()` returns 2) and the script runs on.
fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value {
let mut names: Vec<String> = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect();
names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]);
for name in names {
let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) {
Some(Ok(Some(code))) => code,
read => {
let why = match read {
Some(Err(e)) => format!(": {e}"),
_ => String::new(),
};
let error = format!("{name}: cannot read the script{why}");
log::warn!("{dir}: {error}");
s.events.push(Event::Toast { message: error.clone(), error: true });
failed.push(json!({"name": name, "error": error}));
continue;
}
};
let is_waiting = |r: &Result<Value>| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true));
// In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName`
// and `$.evalFile` find the files beside it.
let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone());
// Windows open before this script runs belong to other scripts, perhaps of the same name.
let before: Vec<u32> = s.script_ui.windows.iter().map(|w| w.id).collect();
let hosts_before = s.script_ui.task_hosts.clone();
let mut r = super::file_more::run_js_file(s, &code, &path, &name);
if !quitting {
// The Startup script's context: the one that scheduled tasks or holds the windows it
// opened (a task scheduled after its dialog closes runs there too).
let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied();
let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host);
let hosts: Vec<u32> = tasks.chain(windows).collect();
for h in hosts {
if !s.script_ui.startup_hosts.contains(&h) {
s.script_ui.startup_hosts.push(h);
}
}
}
let mut closed = 0;
while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) {
let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break };
// report_js reports an error the script throws after its dialog closes, as it does for an
// error in the script's first run.
r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out));
closed += 1;
}
match r {
Ok(_) if is_waiting(&r) => waiting.push(name),
Ok(_) => ran.push(name),
Err(e) => {
// Also in the log: no frame draws a Shutdown script's toast.
log::warn!("{dir}: {e}");
failed.push(json!({"name": name, "error": e.to_string()}));
}
}
}
json!({"ran": ran, "waiting": waiting, "failed": failed})
}
/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at
/// launch, before a project opens).
fn run_startup(s: &mut Session, _: &Value) -> Result<Value> {
Ok(run_folder(s, STARTUP_DIR, false))
}
/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown`
/// (frontends call it as the app quits, after the unsaved-changes prompt).
fn run_shutdown(s: &mut Session, _: &Value) -> Result<Value> {
s.replace_project(effectcraft_project::Project::default(), None);
Ok(run_folder(s, SHUTDOWN_DIR, true))
}
/// Window ▸ `<ScriptUI panel>`: run the panel script (or bring its panel forward).
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
@@ -414,6 +510,24 @@ pub fn specs() -> Vec<CommandSpec> {
always,
restore_panels
),
cmd!(
"file.runStartupScripts",
"Run Startup Scripts",
[],
None,
"{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}",
always,
run_startup
),
cmd!(
"file.runShutdownScripts",
"Run Shutdown Scripts",
[],
None,
"{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}",
always,
run_shutdown
),
crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get),
cmd!(
"script.settings.save",
@@ -948,4 +1062,64 @@ mod tests {
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed");
let _ = std::fs::remove_dir_all(&root);
}
/// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels),
/// as soon as one is installed and until it is uninstalled; a key given to one is saved with
/// the preset.
#[test]
fn file_scripts_entries_take_keyboard_shortcuts() {
use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key};
let (mut s, root) = session_in_tmp("shortcuts");
let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx"));
std::fs::write(&script, "hello").unwrap();
std::fs::write(&panel, "panel").unwrap();
let listed = |s: &Session, name: &str| {
s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone()))
};
assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples");
assert_eq!(listed(&s, "Hello.jsx"), None);
s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap();
s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap();
assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![])));
assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu");
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();
let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"}));
assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}");
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7"));
let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap());
assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()]));
s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap();
assert_eq!(listed(&s, "Hello.jsx"), None);
let _ = std::fs::remove_dir_all(&root);
}
/// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand
/// after the shortcut table was built; a script the folder doesn't hold (never there, or
/// removed by hand) can't take a key, and Run Script File… keeps its own keys.
#[test]
fn shortcut_commands_find_scripts_copied_into_the_folder() {
let (mut s, root) = session_in_tmp("copied");
assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built");
std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap();
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap();
let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap();
assert_eq!(list["count"], 1, "{list}");
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();
assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}");
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"}));
assert!(bad_params(&r), "{r:?}");
// The binding-key form finds a script copied in since the last lookup, too.
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap();
let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap();
assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}");
// Removed by hand: no longer in the folder.
std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap();
std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap();
let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"}));
assert!(bad_params(&r), "{r:?}");
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"}));
assert!(bad_params(&r), "{r:?}");
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key");
let _ = std::fs::remove_dir_all(&root);
}
}
+14 -4
View File
@@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {
// ---------------------------------------------------------------- shortcuts
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`).
fn bindable(s: &Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A
/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it
/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error,
/// not Run Script File….
fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
let t = s.shortcuts();
let key = binding_key(c, &params);
t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {");
if script {
s.scripts_changed();
}
let t = s.shortcuts();
let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) };
found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
}
fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
@@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
}
fn sc_list(s: &mut Session, p: &Value) -> Result<Value> {
// Scripts copied into the Scripts folder since the table was built are listed too.
s.scripts_changed();
let q = str_p(p, "query").unwrap_or("").to_lowercase();
let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false);
let keys_q = str_p(p, "keys").and_then(normalize);
+2
View File
@@ -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
+5 -1
View File
@@ -402,12 +402,16 @@ pub struct ScriptUi {
#[serde(skip)]
pub tasks: Option<ScriptTaskRunner>,
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
/// tasks of scripts without an open window stop then.
/// tasks of scripts without an open window stop then, except those of Startup scripts.
#[serde(skip)]
pub project_epoch: u64,
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
#[serde(skip)]
pub task_hosts: Vec<u32>,
/// The script contexts of Startup scripts: their tasks go on when the project is closed or
/// replaced, until EffectCraft quits.
#[serde(skip)]
pub startup_hosts: Vec<u32>,
}
impl ScriptUi {
+1 -1
View File
@@ -96,7 +96,7 @@ impl Session {
effectcraft_project::ItemKind::Footage(f) if f.has_video => {
let f = f.clone();
Some(Box::new(move || {
let img = footage.frame(item, &f, Tick(0))?;
let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f);
// Point-sample down to at most `max_side` on the long side.
let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32;
let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step));
+12 -2
View File
@@ -195,9 +195,19 @@ impl Session {
self.shortcut_table = std::sync::OnceLock::new();
}
/// The active shortcut preset resolved against every bindable command.
/// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the
/// shortcut table.
pub fn scripts_changed(&mut self) {
self.shortcut_table = std::sync::OnceLock::new();
}
/// The active shortcut preset resolved against every bindable command, File ▸ Scripts'
/// entries included.
pub fn shortcuts(&self) -> &ShortcutTable {
self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands))
self.shortcut_table.get_or_init(|| {
let scripts: Vec<String> = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect();
ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts)
})
}
/// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel.
+33 -2
View File
@@ -1,6 +1,7 @@
//! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts).
//!
//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key
//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts
//! lists, is *bindable*: it has a binding key
//! (the command id, plus its bound parameters for entries such as `app.settings
//! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in
//! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom
@@ -157,6 +158,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
out
}
/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with
/// no default keys. A key is bound to the script's name, so it stays with that script when
/// scripts are added to or removed from the folder.
pub fn script_bindables(names: &[String]) -> Vec<Bindable> {
names
.iter()
.map(|n| {
let params = json!({"name": n});
Bindable {
key: binding_key("file.runScript", &params),
command: "file.runScript".into(),
params,
label: n.clone(),
path: vec!["File".into(), "Scripts".into()],
scope: APP_SCOPE.into(),
defaults: vec![],
}
})
.collect()
}
/// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it
/// has no key.
pub fn normalize(s: &str) -> Option<String> {
@@ -436,7 +458,16 @@ pub struct ShortcutTable {
impl ShortcutTable {
pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable {
let bindables = bindables(ui);
Self::build_with_scripts(maps, ui, &[])
}
/// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed
/// after the other File ▸ Scripts entries.
pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable {
let mut bindables = bindables(ui);
let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1);
let new: Vec<Bindable> = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect();
bindables.splice(at..at, new);
let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect();
ShortcutTable { bindables, keys }
}
+62
View File
@@ -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();
+138 -5
View File
@@ -1,6 +1,6 @@
//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through
//! a synthetic footage source.
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings),
//! rendered through a synthetic footage source.
use std::sync::Arc;
@@ -36,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
"matte_black.png" => [0.0, 0.5, 0.0, 0.5],
"matte_white.png" => [0.5, 1.0, 0.5, 0.5],
"half.png" => [1.0, 1.0, 1.0, 0.25],
// A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands
// them out (code / 1023).
"scan.dpx" | "plate.dpx" => {
let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0;
[v, v, v, 1.0]
}
_ => {
let _ = x;
[1.0, 1.0, 1.0, 1.0]
@@ -47,7 +53,7 @@ fn size_of(path: &str) -> (u32, u32) {
match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") {
"red16.png" => (16, 16),
"blue8.png" => (8, 8),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4),
_ => (10, 10),
}
}
@@ -64,7 +70,12 @@ impl FootageSource for Synthetic {
for y in 0..h {
for x in 0..w {
// Decoders hand out premultiplied pixels (straight file colour × alpha).
let p = file_px(&f.path, x, y);
let mut p = file_px(&f.path, x, y);
// A log scan sequence: code 95 on even frames, 685 on odd ones.
if f.path.ends_with("frames.dpx") {
let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0;
p = [v, v, v, 1.0];
}
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
}
}
@@ -76,7 +87,7 @@ struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
let (w, h) = size_of(path);
let video = path.ends_with(".mov");
let video = path.ends_with(".mov") || path.ends_with("frames.dpx");
Ok(Footage {
path: path.into(),
kind: if video { FootageKind::Video } else { FootageKind::Still },
@@ -431,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() {
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
assert!(foot(&s, w).preserve_rgb);
}
/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the
/// Current Gamma between the converted points (off, they show as stored); the converted points
/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX
/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings.
#[test]
fn cineon_settings_convert_dpx_code_values() {
let mut s = session();
let d = import(&mut s, "/m/scan.dpx");
let other = import(&mut s, "/m/plate.dpx");
let png = import(&mut s, "/m/red16.png");
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => panic!("not footage"),
};
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0];
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
// Off: the code values as stored.
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
// The checked path (control channel, MCP) accepts every Cineon parameter the doc lists.
let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}");
assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1));
assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2));
s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap();
assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2));
// Converted points, in 8-bit units.
let out = s
.execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685}))
.unwrap();
assert_eq!(out["items"][0]["cineon"]["units"], "8bit");
assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}");
assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1));
// The form sends the points in the units it showed them in (`pointUnits`) with a new Units.
s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap();
let f = foot(&s, d);
assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float);
assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon);
// Refused: a black point at or above the white point, values out of range, and footage
// that is not DPX or Cineon. Nothing changes.
let before = foot(&s, d).cineon;
for bad in [
json!({"item": d.0, "tenBitBlack": 700}),
json!({"item": d.0, "tenBitWhite": 2000}),
json!({"item": d.0, "currentGamma": 0}),
json!({"item": d.0, "highlightRolloff": -1}),
json!({"item": d.0, "convertedWhite": 1.5}),
json!({"item": d.0, "cineonUnits": "12bit"}),
json!({"item": png.0, "logConversion": true}),
] {
assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}");
}
assert_eq!(foot(&s, d).cineon, before);
assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default());
// With DPX and other footage selected, only the DPX file takes the settings.
s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap();
assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion);
s.execute("edit.undo", json!({})).unwrap();
assert!(!foot(&s, other).cineon.log_conversion);
// Remember / Apply Interpretation.
s.state.project_selection = vec![d];
s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap();
s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap();
assert_eq!(foot(&s, other).cineon, before);
assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default());
// Off again: as stored.
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap();
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
// Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at
// Current Gamma 1 is 0.5, not the conversion of the blended code values.
let seq = import(&mut s, "/m/frames.dpx");
s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap();
s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap();
let bc = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": seq.0})).unwrap();
s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0];
assert!(near(mid, 0.5), "frame-mixed log frames: {mid}");
// A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in.
s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let inner = s.active_comp_id().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap();
let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0];
assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1));
s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1));
}
/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX
/// footage with its Cineon Settings, as a composition renders it.
#[test]
fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() {
let mut s = session();
let d = import(&mut s, "/m/scan.dpx");
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap();
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0];
assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}");
let f = match &s.project.item(d).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => panic!("not footage"),
};
let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0];
assert!(near(panel, rendered), "Footage panel: {panel}");
let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap();
assert_eq!(w, 4);
let thumb = rgba[8] as f32 / 255.0;
assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}");
let comp = s.project.comp(c).unwrap();
let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default());
let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap();
assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]);
}
+40
View File
@@ -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() {
+31
View File
@@ -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
View File
@@ -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 });
});
})();
+95
View File
@@ -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;
+3
View File
@@ -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);
}
+36 -5
View File
@@ -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])
}
@@ -167,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,
};
@@ -292,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)
@@ -312,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)
+3
View File
@@ -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);
+1 -1
View File
@@ -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);
+15 -11
View File
@@ -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,
+53
View File
@@ -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();
+51 -20
View File
@@ -15,6 +15,7 @@ pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
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};
@@ -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
}
+56
View File
@@ -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));
}
+18 -7
View File
@@ -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;
+18
View File
@@ -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);
}
+48
View File
@@ -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);
+16
View File
@@ -596,6 +596,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
.with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(wiggle_spatial_phase(ids))
.with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))),
"wiggler" | "wiggleTransform" => {
// The Transform group holds how far each transformation wiggles.
let tr = ids
.group("transform", "Transform")
.with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))
.with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))
.with(ids.prop("scale", "Scale", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent))
.with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));
indexed(ids.group("wiggler", "Wiggle Transform 1"))
.with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0)))
.with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
.with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("seed", "Random Seed", Value::Scalar(0.0)))
.with(tr)
}
"merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[
"Merge",
"Add",
+151
View File
@@ -52,6 +52,142 @@ pub enum AlphaMode {
Ignore,
}
/// Interpret Footage ▸ Cineon Settings ▸ Units: how the Converted Black and White Points are
/// shown and entered.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CineonUnits {
/// 10-bit code values (0–1023).
#[default]
TenBit,
/// 8-bit values (0–255).
EightBit,
/// Floating point (0–1).
Float,
}
impl CineonUnits {
pub const ALL: [CineonUnits; 3] = [CineonUnits::TenBit, CineonUnits::EightBit, CineonUnits::Float];
/// The id commands use: `10bit`, `8bit` or `float`.
pub fn id(self) -> &'static str {
match self {
CineonUnits::TenBit => "10bit",
CineonUnits::EightBit => "8bit",
CineonUnits::Float => "float",
}
}
pub fn label(self) -> &'static str {
match self {
CineonUnits::TenBit => "10-bit",
CineonUnits::EightBit => "8-bit",
CineonUnits::Float => "Float",
}
}
pub fn parse(s: &str) -> Option<CineonUnits> {
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"10bit" | "10" => Some(CineonUnits::TenBit),
"8bit" | "8" => Some(CineonUnits::EightBit),
"float" | "32bit" => Some(CineonUnits::Float),
_ => None,
}
}
/// A 10-bit value (0–1023) in these units.
pub fn from_ten_bit(self, v: f64) -> f64 {
match self {
CineonUnits::TenBit => v,
CineonUnits::EightBit => v * 255.0 / 1023.0,
CineonUnits::Float => v / 1023.0,
}
}
/// A value in these units as a 10-bit value (0–1023).
pub fn to_ten_bit(self, v: f64) -> f64 {
match self {
CineonUnits::TenBit => v,
CineonUnits::EightBit => v * 1023.0 / 255.0,
CineonUnits::Float => v * 1023.0,
}
}
}
/// Interpret Footage ▸ Cineon Settings of DPX and Cineon footage: how the file's logarithmic
/// (printing density) code values become the footage's pixels. Code values are counted in 10-bit
/// units whatever the file's bit depth.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CineonSettings {
/// Logarithmic Conversion: convert the code values from log to the Current Gamma. When it
/// is off, the code values are shown as stored.
pub log_conversion: bool,
/// Converted Black Point: the level the 10 Bit Black Point becomes (10-bit units).
pub converted_black: f32,
/// Converted White Point: the level the 10 Bit White Point becomes (10-bit units).
pub converted_white: f32,
/// 10 Bit Black Point: the code value of minimum density.
pub black: f32,
/// 10 Bit White Point: the code value of maximum density.
pub white: f32,
/// Current Gamma: the gamma the converted values are encoded with (1: linear light).
pub gamma: f32,
/// Highlight Rolloff (0–100): values near and above the white point ramp off gradually
/// instead of clipping; 0 keeps over-range values.
pub rolloff: f32,
/// Units of the Converted Black and White Points.
pub units: CineonUnits,
}
impl Default for CineonSettings {
fn default() -> Self {
CineonSettings {
log_conversion: false,
converted_black: 0.0,
converted_white: 1023.0,
black: 95.0,
white: 685.0,
gamma: 1.7,
rolloff: 20.0,
units: CineonUnits::TenBit,
}
}
}
impl CineonSettings {
/// The highest 10-bit value.
pub const MAX: f32 = 1023.0;
/// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the
/// converted black and white levels (0–1; above 1 for over-range highlights).
pub fn convert(&self, v: f32) -> f32 {
let lin = cineon_log_to_lin(v, self.black, self.white, 1.0, self.gamma, self.rolloff);
let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX;
if out.is_finite() { out } else { 0.0 }
}
}
/// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter
/// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit
/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to
/// `scale / gamma`, with values above `1 - rolloff / 100` ramped off toward 1 (Highlight Rolloff
/// 0–100; 0 keeps over-range values). A Gamma below 0.01 is raised to 0.01. Cineon Settings passes
/// a `scale` of 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7 (its default
/// Gamma 1.7 gives linear light).
pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, scale: f32, gamma: f32, rolloff: f32) -> f32 {
// Kodak printing density: 0.002 density per code value, negative gamma 0.6.
let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6);
let offset = exposure(black);
let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0);
lin = lin.powf(scale / gamma.max(0.01));
let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if knee < 1.0 && lin > knee {
let w = 1.0 - knee;
lin = knee + w * (1.0 - (-(lin - knee) / w).exp());
}
lin
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Footage {
pub path: String,
@@ -98,6 +234,9 @@ pub struct Footage {
/// colour profile, Interpret As Linear Light and the working space don't apply.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub preserve_rgb: bool,
/// Interpret Footage ▸ Cineon Settings (DPX and Cineon files only; [`Footage::cineon_applies`]).
#[serde(default, skip_serializing_if = "is_default")]
pub cineon: CineonSettings,
/// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the
/// project so expressions read it everywhere (and on the web).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -149,6 +288,7 @@ impl Default for Footage {
invert_alpha: false,
linear_light: false,
preserve_rgb: false,
cineon: CineonSettings::default(),
data: None,
layer: None,
page: 0,
@@ -160,6 +300,17 @@ impl Default for Footage {
}
impl Footage {
/// The file is DPX or Cineon (the formats Interpret Footage ▸ Cineon Settings applies to).
pub fn is_cineon_file(&self) -> bool {
let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
matches!(ext.as_str(), "dpx" | "cin")
}
/// Cineon Settings ▸ Logarithmic Conversion applies: it is on and the file is DPX or Cineon.
pub fn cineon_applies(&self) -> bool {
self.cineon.log_conversion && self.is_cineon_file()
}
/// Interpret As Linear Light applies (Preserve RGB passes the file's values through).
pub fn linear_light_applies(&self) -> bool {
self.linear_light && !self.preserve_rgb
+1 -1
View File
@@ -36,7 +36,7 @@ mod layer;
mod settings;
pub use comp::{Comp, Guide, Renderer, Resolution};
pub use footage::{AlphaMode, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer};
pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin};
pub use item::{Item, ItemId, ItemKind, LayerId};
pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte};
pub use settings::{
+43
View File
@@ -291,6 +291,49 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() {
assert!(!p.comp_contains(c[n - 1], c[0]));
}
/// Interpret Footage ▸ Cineon Settings: the 10 Bit Black and White Points become the converted
/// points, Current Gamma 1 gives linear light, Highlight Rolloff 0 keeps over-range values and
/// a higher one ramps them off; the settings apply only to DPX and Cineon files, and projects
/// saved without them read the defaults.
#[test]
fn cineon_settings_convert_log_code_values() {
use crate::{CineonSettings, CineonUnits, Footage};
let code = |v: f32| v / 1023.0;
let c = CineonSettings { log_conversion: true, rolloff: 0.0, ..Default::default() };
assert!(c.convert(code(95.0)).abs() < 1e-6, "black point");
assert!((c.convert(code(685.0)) - 1.0).abs() < 1e-5, "white point");
// Code 400: (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251 linear, ^(1/1.7).
assert!((c.convert(code(400.0)) - 0.26190).abs() < 1e-4, "{}", c.convert(code(400.0)));
let linear = CineonSettings { gamma: 1.0, ..c };
assert!((linear.convert(code(400.0)) - 0.10251).abs() < 1e-4, "{}", linear.convert(code(400.0)));
assert!(c.convert(code(1023.0)) > 1.5, "over range kept: {}", c.convert(code(1023.0)));
// The default rolloff (20) ramps the white point and above off, up to 1.
let rolled = CineonSettings { rolloff: 20.0, ..c };
let (w, mid, top) = (rolled.convert(code(685.0)), rolled.convert(code(720.0)), rolled.convert(code(1023.0)));
assert!(w > 0.8 && w < mid && mid < top && top <= 1.0, "{w} {mid} {top}");
let points = CineonSettings { converted_black: 64.0, converted_white: 940.0, ..c };
assert!((points.convert(code(95.0)) - 64.0 / 1023.0).abs() < 1e-5);
assert!((points.convert(code(685.0)) - 940.0 / 1023.0).abs() < 1e-5);
// Units.
assert!((CineonUnits::EightBit.from_ten_bit(1023.0) - 255.0).abs() < 1e-9);
assert!((CineonUnits::Float.to_ten_bit(0.5) - 511.5).abs() < 1e-9);
assert_eq!(CineonUnits::parse("8-bit"), Some(CineonUnits::EightBit));
assert!(CineonUnits::ALL.iter().all(|u| CineonUnits::parse(u.id()) == Some(*u)));
// DPX and Cineon files only, and only with Logarithmic Conversion on.
let on = CineonSettings { log_conversion: true, ..Default::default() };
let dpx = Footage { path: "/m/plate.0001.DPX".into(), cineon: on, ..Default::default() };
assert!(dpx.cineon_applies());
assert!(Footage { path: "/m/scan.cin".into(), cineon: on, ..Default::default() }.cineon_applies());
assert!(!Footage { path: "/m/still.png".into(), cineon: on, ..Default::default() }.cineon_applies());
assert!(!Footage { path: "/m/plate.dpx".into(), ..Default::default() }.cineon_applies());
// Saved only when changed; read back, and absent in older projects.
let plain = serde_json::to_value(Footage { path: "/m/a.dpx".into(), ..Default::default() }).unwrap();
assert!(plain.get("cineon").is_none(), "{plain}");
let saved = serde_json::to_string(&dpx).unwrap();
assert_eq!(serde_json::from_str::<Footage>(&saved).unwrap().cineon, on);
assert_eq!(serde_json::from_value::<Footage>(plain).unwrap().cineon, CineonSettings::default());
}
/// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack.
#[test]
fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() {
+8
View File
@@ -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).
+1 -1
View File
@@ -458,7 +458,7 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool {
if matches!(layer.source, LayerSource::Shape)
&& let Some(c) = layer.props.sub("contents")
{
return group_has(c, "wiggle");
return group_has(c, "wiggle") || group_has(c, "wiggler");
}
// Wiggly selectors move with time; Expression selectors evaluate per character (their
// property value alone doesn't capture the result).
+19 -1
View File
@@ -1,6 +1,7 @@
use super::footage::NoFootage;
use super::opts::RenderOpts;
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
use rayon::prelude::*;
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
@@ -33,6 +34,23 @@ pub fn field_frame(img: &Image, parity: usize) -> Image {
out
}
/// Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion converts the log code values of
/// a decoded (premultiplied) frame of `f` to the Current Gamma. When the conversion does not
/// apply, the frame is returned unchanged.
pub fn cineon_frame(img: Arc<Image>, f: &Footage) -> Arc<Image> {
if !f.cineon_applies() {
return img;
}
let cs = f.cineon;
let mut out = (*img).clone();
out.data.par_iter_mut().for_each(|p| {
let a = p[3];
let c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }.map(|v| cs.convert(v));
*p = [c[0] * a, c[1] * a, c[2] * a, a];
});
Arc::new(out)
}
/// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied)
/// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]).
pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image {
+2 -2
View File
@@ -48,8 +48,8 @@ mod pipeline;
pub use accel::{Accelerator, Backend, FxStep, FxTarget};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage};
pub use frames::{
adaptive_samples, blend_frames, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame,
roi_inside,
adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path,
render_frame, roi_inside,
};
#[cfg(test)]
use fx_host::FxHost;
+33 -15
View File
@@ -1,6 +1,6 @@
use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget};
use super::footage::{FootageSource, is_vector_footage};
use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside};
use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside};
use super::fx_host::{FxHost, essential_overrides};
use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through};
use super::{
@@ -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] {
@@ -817,16 +829,17 @@ impl<'a> Renderer<'a> {
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
let i = field_rate.frame_at(t).max(0);
let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?;
let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f);
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
// Upper field = even lines (0, 2, …).
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f))));
}
// Interpretation is per pixel but not linear: it runs on the frame before resampling.
let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies()));
let per_pixel = f.invert_alpha || f.linear_light_applies();
let size = size.filter(|_| !(per_pixel || f.cineon_applies()));
let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?;
if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) }
if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) }
}
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
@@ -844,6 +857,10 @@ impl<'a> Renderer<'a> {
if f.alpha == effectcraft_project::AlphaMode::Premultiplied {
f.premul_color.map(f32::to_bits).hash(&mut h);
}
if f.cineon_applies() {
let c = &f.cineon;
[c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h);
}
h.finish()
});
if let (Some(c), Some(k)) = (self.cache, key)
@@ -851,8 +868,9 @@ impl<'a> Renderer<'a> {
{
return Some(Arc::new(b.img.clone()));
}
let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float);
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float);
// Log scans are converted before they blend: the conversion is not linear.
let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f));
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f));
if let (Some(a), Some(b)) = (a, b) {
let img = blend_frames(mode, &a, &b, w as f32);
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
@@ -862,7 +880,7 @@ impl<'a> Renderer<'a> {
}
}
}
self.footage.frame_with(item, f, t, size, float)
self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f))
}
/// Source → masks, clamped/quantised to the project depth.
+80 -3
View File
@@ -2,8 +2,8 @@
//! with the After Effects stacking semantics:
//!
//! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag,
//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including
//! the paths of nested groups;
//! Wiggle Paths, Wiggle Transform, Merge Paths, Repeater) act on all paths above them in the
//! same group, including the paths of nested groups;
//! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified
//! by every operator in the stack* — a Trim Paths below a Stroke still trims it;
//! - items higher in the list draw on top;
@@ -17,7 +17,9 @@ use effectcraft_keyframe::{Gradient, Value};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_raster::{Image, Mask};
use effectcraft_text::selectors::{self, Wiggly};
use rayon::prelude::*;
use std::collections::{HashMap, HashSet};
use crate::eval::EvalCtx;
@@ -185,9 +187,75 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec<usize> {
v
}
/// Wiggle Transform: moves the live slots by a random offset of the Transform group's Anchor
/// Point, Position, Scale and Rotation (the most each one wiggles), changing over time. Its
/// Wiggles/Second, Correlation, Temporal Phase, Spatial Phase and Random Seed work as the Wiggly
/// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above
/// the operation move on their own as far as Correlation allows, and the copies of a Repeater
/// below it move as one. The four transformations of one operation move together.
fn wiggle_transform(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, arena: &mut [BezPath], live: &[usize], copy_of: &HashMap<usize, usize>, draws: &mut [Draw]) {
let Some(tr) = g.sub("transform") else { return };
let w = Wiggly {
max: 1.0,
min: -1.0,
wiggles_per_second: ctx.f(layer, g, "speed", 2.0),
correlation: ctx.f(layer, g, "correlation", 50.0) / 100.0,
temporal_phase: ctx.f(layer, g, "phase", 0.0),
spatial_phase: ctx.f(layer, g, "spatialPhase", 0.0),
lock_dimensions: false,
seed: ctx.f(layer, g, "seed", 0.0).round() as i64 as u64,
};
let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);
let pos = ctx.v2(layer, tr, "position", [0.0; 2]);
let scale = ctx.v2(layer, tr, "scale", [0.0; 2]);
let rot = ctx.f(layer, tr, "rotation", 0.0);
let t = layer.layer_time(ctx.time).seconds();
let matrix = |k: usize| {
let (x, y) = (selectors::wiggly_value(&w, t, k, 0), selectors::wiggly_value(&w, t, k, 1));
Mat3::translate(vec2(pos[0] * x, pos[1] * y))
* Mat3::rotate_deg(rot * x)
* Mat3::scale(vec2(1.0 + scale[0] * x / 100.0, 1.0 + scale[1] * y / 100.0))
* Mat3::translate(vec2(-anchor[0] * x, -anchor[1] * y))
};
let mut by_copy: HashMap<usize, Mat3> = HashMap::new();
let mut done: HashSet<usize> = HashSet::new();
for &s in live {
if !done.insert(s) {
continue;
}
let k = copy_of.get(&s).copied().unwrap_or(0);
let m = *by_copy.entry(k).or_insert_with(|| matrix(k));
arena[s] = mat_path(&arena[s], &m);
}
// A paint above the operation whose paths all moved by one matrix moves its gradient and
// stroke scale with them, as a group transform does. A paint over copies that moved by
// different matrices stays one paint (its fill rule and opacity apply to all of them at once)
// and keeps its gradient and stroke scale.
for d in draws {
let mut ms = d.slots.iter().map(|s| done.contains(s).then(|| by_copy.get(&copy_of.get(s).copied().unwrap_or(0)).copied()).flatten());
let Some(Some(m)) = ms.next() else { continue };
if ms.all(|x| x == Some(m)) {
d.xf = m * d.xf;
}
}
}
/// Collect the draws of a contents group and the path slots live at its end (in group space).
/// `arena` holds every path slot; operators rewrite slots in place.
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>) -> (Vec<Draw>, Vec<usize>) {
collect_in(ctx, layer, contents, arena, &mut Copies::default())
}
/// The Repeater copy each path slot belongs to, for Wiggle Transform: `of` maps a slot a Repeater
/// made to its copy's id (slots no Repeater made are copy 0). Ids are unique across the layer's
/// groups, so copies made in a nested group stay apart in the groups around it.
#[derive(Default)]
struct Copies {
of: HashMap<usize, usize>,
next: usize,
}
fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>, copy_ids: &mut Copies) -> (Vec<Draw>, Vec<usize>) {
let mut draws: Vec<Draw> = Vec::new();
let mut live: Vec<usize> = Vec::new();
let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
@@ -330,6 +398,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
let t = layer.layer_time(ctx.time).seconds();
apply(arena, &live, |p| ops::wiggle(p, &w, t));
}
"wiggler" => wiggle_transform(ctx, layer, g, arena, &live, &copy_ids.of, &mut draws),
"merge" => {
let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode"));
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
@@ -342,7 +411,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
}
"group" => {
let Some(inner) = g.sub("contents") else { continue };
let (cd, cl) = collect(ctx, layer, inner, arena);
let (cd, cl) = collect_in(ctx, layer, inner, arena, copy_ids);
let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0));
for i in used_slots(&cl, &cd) {
arena[i] = mat_path(&arena[i], &m);
@@ -379,12 +448,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
// Each copy gets its own transformed slots, so operators below the repeater act on
// the repeated geometry (copy order: original first).
let mut copy_maps: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(n);
// Each copy of each earlier copy (a second Repeater) gets an id of its own.
let mut ids: HashMap<(usize, usize), usize> = HashMap::new();
for i in 0..n {
let m = step(i as f64 + offset);
let mut map = std::collections::HashMap::new();
for &s in &base_slots {
arena.push(mat_path(&arena[s], &m));
map.insert(s, arena.len() - 1);
let base = copy_ids.of.get(&s).copied().unwrap_or(0);
let id = *ids.entry((i, base)).or_insert_with(|| {
copy_ids.next += 1;
copy_ids.next
});
copy_ids.of.insert(arena.len() - 1, id);
}
live.extend(base_live.iter().map(|s| map[s]));
copy_maps.push(map);
+196
View File
@@ -310,6 +310,202 @@ fn wiggle_paths_vary_over_time_deterministically() {
assert_ne!(a.data, c.data);
}
/// Alpha-weighted centroid of the pixels in columns `x0..x1`.
fn centroid(img: &Image, x0: i64, x1: i64) -> [f32; 2] {
let (mut sx, mut sy, mut sa) = (0.0, 0.0, 0.0);
for x in x0..x1 {
for y in 0..img.height as i64 {
let a = alpha(img, x, y);
sx += a * x as f32;
sy += a * y as f32;
sa += a;
}
}
[sx / sa, sy / sa]
}
fn wiggle_transform(ids: &mut Ids, params: &[(&str, Value)]) -> PropGroup {
op(ids, "wiggler", params)
}
#[test]
fn wiggle_transform_moves_paths_over_time() {
let run = |position: [f64; 2], t: f64| {
let (p, cid) = shape_comp(|ids| {
let w = wiggle_transform(ids, &[("transform/position", Value::Vec2(position))]);
vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)]
});
render_frame(&p, cid, Tick::from_seconds_f64(t), 1.0)
};
// The defaults wiggle nothing: the square stays centered on pixel 99.5, 49.5.
let rest = [99.5, 49.5];
assert_eq!(centroid(&run([0.0, 0.0], 0.37), 0, 200), rest);
let a = run([15.0, 15.0], 0.37);
assert_eq!(a.data, run([15.0, 15.0], 0.37).data, "the same time gives the same frame");
let b = run([15.0, 15.0], 1.21);
let (ca, cb) = (centroid(&a, 0, 200), centroid(&b, 0, 200));
assert!(ca != rest && ca != cb, "{ca:?} {cb:?}");
for c in [ca, cb] {
assert!((c[0] - rest[0]).abs() <= 15.01 && (c[1] - rest[1]).abs() <= 15.01, "{c:?}");
}
// A translation keeps the square's area.
assert!((coverage(&a) - 400.0).abs() < 1.0, "{}", coverage(&a));
// The layer cache keys a layer with a Wiggle Transform by time.
let (p, cid) = shape_comp(|ids| vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), wiggle_transform(ids, &[]), build::shape_fill(ids, RED)]);
let comp = p.comp(cid).unwrap();
let key = |t: f64| crate::cache::layer_key(&crate::eval::EvalCtx::new(&p, cid, comp, Tick::from_seconds_f64(t)), &comp.layers[0], 1.0, false, false);
assert_ne!(key(0.37), key(1.21));
}
#[test]
fn wiggle_transform_scales_and_rotates() {
let run = |params: &[(&str, Value)]| {
let (p, cid) = shape_comp(|ids| {
let w = wiggle_transform(ids, params);
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)]
});
render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0)
};
let scaled = coverage(&run(&[("transform/scale", Value::Vec2([50.0, 50.0]))]));
assert!((scaled - 1600.0).abs() > 20.0, "{scaled}");
let rotated = run(&[("transform/rotation", Value::Scalar(45.0))]);
assert!((coverage(&rotated) - 1600.0).abs() < 2.0, "{}", coverage(&rotated));
assert!(alpha(&rotated, 81, 31) < 0.5 || alpha(&rotated, 119, 31) < 0.5, "a corner turned away");
}
/// A gradient above a Wiggle Transform moves with the paths it paints, as under a group transform.
#[test]
fn wiggle_transform_moves_the_gradient_with_the_path() {
let (p, cid) = shape_comp(|ids| {
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
vec![
build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0),
build::shape_gradient_fill(ids, false, [-20.0, 0.0], [20.0, 0.0], g),
wiggle_transform(ids, &[("transform/position", Value::Vec2([60.0, 0.0]))]),
]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
let cols: Vec<i64> = (0..200).filter(|&x| alpha(&img, x, 50) > 0.99).collect();
let (l, r) = (cols[0], cols[cols.len() - 1]);
assert!((l - 80).abs() > 3, "the square moved: {l}");
let (cl, cr) = (img.get(l, 50), img.get(r, 50));
assert!(cl[0] < 0.1 && cr[0] > 0.9, "the ramp runs edge to edge: {cl:?} {cr:?}");
}
/// A gradient that paints every copy of a Repeater moves with the copies when they wiggle alike
/// (Correlation 100).
#[test]
fn wiggle_transform_moves_a_gradient_shared_by_repeater_copies() {
let (p, cid) = shape_comp(|ids| {
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
vec![
build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0),
build::shape_repeater(ids, 3.0, [60.0, 0.0]),
build::shape_gradient_fill(ids, false, [0.0, -10.0], [0.0, 10.0], g),
wiggle_transform(ids, &[("correlation", Value::Scalar(100.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),
]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);
for cx in [40, 100, 160] {
let rows: Vec<i64> = (0..100).filter(|&y| alpha(&img, cx, y) > 0.99).collect();
let (t, b) = (rows[0], rows[rows.len() - 1]);
assert!((t - 40).abs() > 3, "copy at {cx} moved: {t}");
let (ct, cb) = (img.get(cx, t), img.get(cx, b));
assert!(ct[0] < 0.1 && cb[0] > 0.9, "copy at {cx}: {ct:?} {cb:?}");
}
}
/// A gradient over copies that wiggle on their own keeps its place: every copy shows the ramp's
/// middle at the group's origin row.
#[test]
fn wiggle_transform_keeps_a_gradient_over_copies_wiggling_apart() {
let (p, cid) = shape_comp(|ids| {
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
vec![
build::shape_rect(ids, [20.0, 40.0], [-60.0, 0.0], 0.0),
build::shape_repeater(ids, 3.0, [60.0, 0.0]),
build::shape_gradient_fill(ids, false, [0.0, -20.0], [0.0, 20.0], g),
wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),
]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);
for cx in [40, 100, 160] {
let c = img.get(cx, 50);
assert!(c[3] > 0.99 && (c[0] - 0.5).abs() < 0.05, "copy at {cx}: {c:?}");
}
}
/// Copies wiggling on their own under one 50 % fill stay one paint: where they overlap, the fill
/// covers them once.
#[test]
fn wiggle_transform_keeps_one_fill_over_overlapping_copies() {
let (p, cid) = shape_comp(|ids| {
let mut fill = build::shape_fill(ids, RED);
set(&mut fill, "opacity", Value::Scalar(50.0));
vec![
build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0),
build::shape_repeater(ids, 3.0, [20.0, 0.0]),
fill,
wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),
]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);
let top = img.data.iter().map(|px| px[3]).fold(0.0f32, f32::max);
assert!((top - 0.5).abs() < 0.01, "{top}");
}
/// A long stack of Repeaters, each cleared by a zero-copy Repeater, still renders the last Repeater's copies.
#[test]
fn long_repeater_stack_with_cleared_copies_renders() {
let (p, cid) = shape_comp(|ids| {
let mut items = vec![];
for _ in 0..70 {
items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0));
items.push(build::shape_repeater(ids, 3.0, [10.0, 0.0]));
items.push(build::shape_repeater(ids, 0.0, [10.0, 0.0]));
}
items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0));
items.push(build::shape_repeater(ids, 3.0, [30.0, 0.0]));
items.push(wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]));
items.push(build::shape_fill(ids, RED));
items
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
assert!((coverage(&img) - 1200.0).abs() < 2.0, "{}", coverage(&img));
}
/// Copies a Repeater makes above a Wiggle Transform wiggle on their own (Correlation 0) or as one
/// (Correlation 100); with the Wiggle Transform above the Repeater every copy wiggles the same.
#[test]
fn wiggle_transform_and_repeater_order() {
let offsets = |wiggle_first: bool, correlation: f64| {
let (p, cid) = shape_comp(|ids| {
let rect = build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0);
let rep = build::shape_repeater(ids, 3.0, [60.0, 0.0]);
let w = wiggle_transform(ids, &[("correlation", Value::Scalar(correlation)), ("transform/position", Value::Vec2([0.0, 15.0]))]);
let fill = build::shape_fill(ids, RED);
if wiggle_first { vec![rect, w, rep, fill] } else { vec![rect, rep, w, fill] }
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
[(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0)
};
let own = offsets(false, 0.0);
assert!((own[0] - own[1]).abs() > 0.5 || (own[1] - own[2]).abs() > 0.5, "copies wiggle on their own: {own:?}");
// A Repeater inside a group above the Wiggle Transform: its copies still wiggle on their own.
let (p, cid) = shape_comp(|ids| {
let inner = vec![build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), build::shape_repeater(ids, 3.0, [60.0, 0.0]), build::shape_fill(ids, RED)];
let group = build::shape_group(ids, "Repeated", inner);
vec![group, wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
let nested = [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0);
assert!((nested[0] - nested[1]).abs() > 0.5 || (nested[1] - nested[2]).abs() > 0.5, "nested copies: {nested:?}");
for same in [offsets(false, 100.0), offsets(true, 0.0)] {
assert!(same[0].abs() > 0.5, "{same:?}");
assert!((same[0] - same[1]).abs() < 0.05 && (same[1] - same[2]).abs() < 0.05, "copies wiggle as one: {same:?}");
}
}
#[test]
fn zigzag_and_twist_render() {
let (p, cid) = shape_comp(|ids| {
+8
View File
@@ -185,6 +185,7 @@ pub const SHAPE_ITEMS: &[(&str, &str)] = &[
("trim", "ADBE Vector Filter - Trim"),
("twist", "ADBE Vector Filter - Twist"),
("wiggle", "ADBE Vector Filter - Roughen"),
("wiggler", "ADBE Vector Filter - Wiggler"),
("zigzag", "ADBE Vector Filter - Zigzag"),
];
@@ -246,6 +247,12 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[
("pucker", "amount", "ADBE Vector PuckerBloat Amount"),
("twist", "angle", "ADBE Vector Twist Angle"),
("twist", "center", "ADBE Vector Twist Center"),
("wiggler", "speed", "ADBE Vector Xform Temporal Freq"),
("wiggler", "correlation", "ADBE Vector Correlation"),
("wiggler", "phase", "ADBE Vector Temporal Phase"),
("wiggler", "spatialPhase", "ADBE Vector Spatial Phase"),
("wiggler", "seed", "ADBE Vector Random Seed"),
("wiggler", "transform", "ADBE Vector Wiggler Transform"),
("merge", "mode", "ADBE Vector Merge Type"),
("wiggle", "size", "ADBE Vector Roughen Size"),
("wiggle", "detail", "ADBE Vector Roughen Detail"),
@@ -439,6 +446,7 @@ pub fn shape_kind(name: &str) -> Option<&'static str> {
"twist" => "twist",
"zigzag" => "zigzag",
"wigglepaths" => "wiggle",
"wiggletransform" => "wiggler",
"mergepaths" => "merge",
_ => return None,
})
+2 -1
View File
@@ -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 });
};
+1 -1
View File
@@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue {
fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let id = arg_string(args, 0, ctx)?;
let mut params = arg_json(args, 1, ctx)?;
if id == "script.run" || id == "file.runScript" {
if effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id.as_str()) {
return Err(throw("scripts can't run scripts"));
}
let keys = params.as_object_mut().and_then(|o| o.remove("__keys"));
+31
View File
@@ -17,6 +17,7 @@ mod include;
mod panels;
#[cfg(not(windows))]
mod services;
mod startup;
mod tasks;
mod ui;
mod ui_more;
@@ -371,6 +372,31 @@ fn match_names_and_lookups() {
assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0]));
}
/// Scripts add a Wiggle Transform by its match name or display name and reach its properties.
#[test]
fn wiggle_transform_by_match_name() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("W", 200, 200, 1, 2, 30);
var sh = c.layers.addShape();
var root = sh.property("ADBE Root Vectors Group");
var w = root.addProperty("ADBE Vector Filter - Wiggler");
var byName = root.addProperty("Wiggle Transform");
w.property("ADBE Vector Wiggler Transform").property("Position").setValue([20, 10]);
[
w.matchName, w.name, byName.matchName, w.property("ADBE Vector Xform Temporal Freq").value,
w.property("ADBE Vector Spatial Phase").name, w.property("ADBE Vector Wiggler Transform").name
]
"#,
);
assert_eq!(o.result, json!(["ADBE Vector Filter - Wiggler", "Wiggle Transform 1", "ADBE Vector Filter - Wiggler", 2, "Spatial Phase", "Transform"]));
let (_, c) = comp_named(&s, "W");
let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
assert_eq!(sh.props.prop("contents/wiggler/transform/position").unwrap().value, KV::Vec2([20.0, 10.0]));
}
#[test]
fn keyframes_eases_and_interpolation() {
let mut s = session();
@@ -1062,6 +1088,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);
}
+290
View File
@@ -0,0 +1,290 @@
//! Startup and Shutdown scripts, as in After Effects: the scripts in the user's `Scripts/Startup`
//! and `Scripts/Shutdown` folders (in the settings folder) run in alphabetical order at launch and
//! as the app quits, after the project is closed.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR, SCRIPTS_DIR, SHUTDOWN_DIR, STARTUP_DIR};
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
use effectcraft_engine::scriptui::WindowKind;
use serde_json::json;
fn session(store: &Arc<MemoryConfig>) -> Session {
let mut s = Session::default();
crate::install(&mut s);
s.config = Some(store.clone());
s
}
/// A script that appends `mark` to the `Startup Test` / `order` script setting.
fn mark(mark: &str) -> String {
format!(
r#"var k = app.settings.haveSetting("Startup Test", "order") ? app.settings.getSetting("Startup Test", "order") : "";
app.settings.saveSetting("Startup Test", "order", k + "{mark}");"#
)
}
fn setting(s: &mut Session, key: &str) -> serde_json::Value {
s.execute("script.settings.get", json!({"section": "Startup Test", "key": key})).unwrap()["value"].clone()
}
#[test]
fn startup_scripts_run_in_alphabetical_order_and_a_failing_one_does_not_stop_the_rest() {
let store = Arc::new(MemoryConfig::default());
store.write(&format!("{STARTUP_DIR}/b.jsx"), &mark("b")).unwrap();
store.write(&format!("{STARTUP_DIR}/A.jsx"), &mark("A")).unwrap();
store.write(&format!("{STARTUP_DIR}/B2.jsx"), &mark("B")).unwrap();
store.write(&format!("{STARTUP_DIR}/C.js"), "throw new Error('no');").unwrap();
store.write(&format!("{STARTUP_DIR}/d.jsx"), &mark("d")).unwrap();
// Not scripts, or not in the Startup folder: not run.
store.write(&format!("{STARTUP_DIR}/notes.txt"), &mark("x")).unwrap();
store.write(&format!("{SCRIPTS_DIR}/Menu Script.jsx"), &mark("m")).unwrap();
store.write(&format!("{SHUTDOWN_DIR}/Quit.jsx"), &mark("q")).unwrap();
let mut s = session(&store);
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
assert_eq!(r["ran"], json!(["A.jsx", "b.jsx", "B2.jsx", "d.jsx"]), "{r}");
let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(failed, ["C.js"]);
assert_eq!(setting(&mut s, "order"), "AbBd");
// The failing script is reported in a toast.
let events = s.drain_events();
assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("C.js"))), "{events:?}");
// The Startup folder's scripts are not offered in File ▸ Scripts.
let list = s.execute("file.scripts.list", json!({})).unwrap();
assert!(list.as_array().unwrap().iter().all(|e| e["name"] != "b.jsx"), "{list}");
}
#[test]
fn shutdown_scripts_run_after_the_project_is_closed() {
let store = Arc::new(MemoryConfig::default());
let probe = r#"app.settings.saveSetting("Startup Test", "items", String(app.project.numItems));"#;
store.write(&format!("{SHUTDOWN_DIR}/Save State.jsx"), probe).unwrap();
let mut s = session(&store);
s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap();
assert!(!s.project.items.is_empty());
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
assert_eq!(r, json!({"ran": ["Save State.jsx"], "waiting": [], "failed": []}));
assert_eq!(setting(&mut s, "items"), "0");
assert!(s.project.items.is_empty());
}
#[test]
fn a_startup_dialog_stays_open_and_its_script_goes_on_when_it_closes() {
let store = Arc::new(MemoryConfig::default());
store.write(&format!("{STARTUP_DIR}/A Dialog.jsx"), &format!("new Window('dialog', 'Welcome').show();\n{}", mark("A"))).unwrap();
store.write(&format!("{STARTUP_DIR}/B.jsx"), &mark("B")).unwrap();
let mut s = session(&store);
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
assert_eq!(r, json!({"ran": ["B.jsx"], "waiting": ["A Dialog.jsx"], "failed": []}));
assert_eq!(setting(&mut s, "order"), "B");
assert!(s.script_ui.windows.iter().any(|w| w.modal && w.title == "Welcome"));
s.execute("scriptui.close", json!({})).unwrap();
assert_eq!(setting(&mut s, "order"), "BA");
}
#[test]
fn startup_scripts_tasks_go_on_after_a_project_opens() {
use web_time::{Duration, Instant};
let store = Arc::new(MemoryConfig::default());
store.write(&format!("{STARTUP_DIR}/A Poll.jsx"), "app.scheduleTask('writeLn(\"poll\")', 100, true);").unwrap();
// A task scheduled after the script's dialog closes.
store.write(&format!("{STARTUP_DIR}/B Ask.jsx"), "new Window('dialog', 'Ask').show();\napp.scheduleTask('writeLn(\"ask\")', 100, true);").unwrap();
let mut s = session(&store);
s.execute("file.runStartupScripts", json!({})).unwrap();
s.execute("scriptui.close", json!({})).unwrap();
// The launch opens a project after the Startup scripts ran.
s.execute("file.closeProject", json!({})).unwrap();
let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(500));
assert_eq!(r["ran"], json!(2), "{r}");
// A script run from File ▸ Scripts still stops when the project closes.
s.execute("script.run", json!({"code": "app.scheduleTask('writeLn(\"menu\")', 100, true);", "name": "Menu.jsx"})).unwrap();
s.execute("file.closeProject", json!({})).unwrap();
let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(5000));
assert_eq!(r["ran"], json!(2), "{r}");
}
#[test]
fn shutdown_dialogs_close_as_if_canceled() {
let store = Arc::new(MemoryConfig::default());
let code = r#"var a = new Window("dialog", "One").show();
var b = new Window("dialog", "Two").show();
app.settings.saveSetting("Startup Test", "shown", a + "," + b);"#;
store.write(&format!("{SHUTDOWN_DIR}/Ask.jsx"), code).unwrap();
// An error after the dialog closes is a failure.
store.write(&format!("{SHUTDOWN_DIR}/Broken.jsx"), "new Window('dialog', 'Three').show();\nthrow new Error('late');").unwrap();
let mut s = session(&store);
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
assert_eq!((&r["ran"], &r["waiting"]), (&json!(["Ask.jsx"]), &json!([])), "{r}");
assert_eq!(r["failed"][0]["name"], "Broken.jsx", "{r}");
assert!(r["failed"][0]["error"].as_str().is_some_and(|e| e.contains("late")), "{r}");
assert_eq!(setting(&mut s, "shown"), "2,2");
assert!(s.script_ui.windows.is_empty());
}
#[test]
fn shutdown_closes_only_its_own_scripts_dialogs() {
let store = Arc::new(MemoryConfig::default());
store.write(&format!("{STARTUP_DIR}/Same.jsx"), &format!("new Window('dialog', 'Startup').show();\n{}", mark("S"))).unwrap();
store.write(&format!("{SHUTDOWN_DIR}/Same.jsx"), &format!("new Window('dialog', 'Shutdown').show();\n{}", mark("Q"))).unwrap();
let mut s = session(&store);
assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap()["waiting"], json!(["Same.jsx"]));
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
assert_eq!(r, json!({"ran": ["Same.jsx"], "waiting": [], "failed": []}));
// The Startup script's dialog is still open, and its script has not gone on.
assert_eq!(setting(&mut s, "order"), "Q");
let titles: Vec<&str> = s.script_ui.windows.iter().map(|w| w.title.as_str()).collect();
assert_eq!(titles, ["Startup"]);
}
#[test]
fn oversized_scripts_are_not_run() {
let store = Arc::new(MemoryConfig::default());
let big = format!("{}\n{}", "//".repeat(8 << 20), mark("x"));
store.write(&format!("{STARTUP_DIR}/Big.jsx"), &big).unwrap();
let mut s = session(&store);
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
assert_eq!(r["ran"], json!([]));
assert_eq!(r["failed"][0]["name"], "Big.jsx");
let events = s.drain_events();
assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("Big.jsx"))), "{events:?}");
assert_eq!(setting(&mut s, "order"), "");
}
#[test]
fn scripts_in_a_settings_folder_on_disk_include_files_beside_them() {
use effectcraft_engine::config::DirConfig;
let root = std::env::temp_dir().join(format!("ec-startup-include-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for (file, code) in [
(format!("{STARTUP_DIR}/lib/helper.jsx"), mark("i")),
(format!("{STARTUP_DIR}/Main.jsx"), format!("#include \"lib/helper.jsx\"\n{}", mark("m"))),
(format!("{SHUTDOWN_DIR}/Ask.jsx"), format!("new Window('dialog', 'Bye').show();\n{}", mark("q"))),
] {
let path = root.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, code).unwrap();
}
let mut s = Session::default();
crate::install(&mut s);
s.config = Some(Arc::new(DirConfig::new(&root)));
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
assert_eq!(r, json!({"ran": ["Main.jsx"], "waiting": [], "failed": []}));
assert_eq!(setting(&mut s, "order"), "im");
// A Shutdown script run as its file still has its dialog closed.
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
assert_eq!(r, json!({"ran": ["Ask.jsx"], "waiting": [], "failed": []}));
assert_eq!(setting(&mut s, "order"), "imq");
let _ = std::fs::remove_dir_all(&root);
}
/// A session with one comp, for checking that a refused command leaves the project open.
fn session_with_comp(store: &Arc<MemoryConfig>) -> Session {
let mut s = session(store);
s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap();
assert!(!s.project.items.is_empty());
s
}
const FOLDER_COMMANDS: [&str; 2] = ["file.runStartupScripts", "file.runShutdownScripts"];
#[test]
fn scripts_cannot_run_the_startup_or_shutdown_folders() {
let store = Arc::new(MemoryConfig::default());
for id in FOLDER_COMMANDS {
let mut s = session_with_comp(&store);
let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {{}})")})).unwrap();
assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}");
assert!(!s.project.items.is_empty(), "{id}");
}
}
#[test]
fn command_scripts_cannot_run_the_startup_or_shutdown_folders() {
let store = Arc::new(MemoryConfig::default());
for id in FOLDER_COMMANDS {
let mut s = session_with_comp(&store);
let e = s.execute("file.runScript", json!({"steps": [{"command": id}]})).unwrap_err().to_string();
assert!(e.contains("scripts can't run scripts"), "{id}: {e}");
assert!(!s.project.items.is_empty(), "{id}");
}
}
#[test]
fn batches_cannot_run_the_startup_or_shutdown_folders() {
let store = Arc::new(MemoryConfig::default());
for id in FOLDER_COMMANDS {
let mut s = session_with_comp(&store);
let e = s.execute("engine.batch", json!({"steps": [{"command": id}]})).unwrap_err().to_string();
assert!(e.contains("can't run inside a batch"), "{id}: {e}");
assert!(!s.project.items.is_empty(), "{id}");
}
}
/// A ScriptUI panel whose script appends `p` to the `Startup Test` / `order` setting each time it
/// runs, remembered as open at the last quit.
fn session_with_panel(store: &Arc<MemoryConfig>) -> Session {
store.write(&format!("{PANELS_DIR}/Mark Panel.jsx"), &format!("this.add('statictext', undefined, 'Mark');\n{}", mark("p"))).unwrap();
store.write(OPEN_PANELS_FILE, &json!({"open": ["Mark Panel.jsx"]}).to_string()).unwrap();
session(store)
}
fn panel_open(s: &Session) -> bool {
s.script_ui.windows.iter().any(|w| w.kind == WindowKind::Panel && w.script == "Mark Panel.jsx")
}
/// The commands that open ScriptUI panels, which run the panel's script.
fn panel_commands() -> [(&'static str, serde_json::Value); 2] {
[("window.scriptPanel", json!({"name": "Mark Panel.jsx"})), ("window.restoreScriptPanels", json!({}))]
}
#[test]
fn scripts_cannot_open_script_panels() {
for (id, params) in panel_commands() {
let store = Arc::new(MemoryConfig::default());
let mut s = session_with_panel(&store);
// Opened by the user: a script's call would only bring it forward.
s.execute("window.scriptPanel", json!({"name": "Mark Panel.jsx"})).unwrap();
let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {params})")})).unwrap();
assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}");
assert_eq!(setting(&mut s, "order"), "p", "{id}");
assert_eq!(store.read(OPEN_PANELS_FILE).unwrap_or_default(), json!({"open": ["Mark Panel.jsx"]}).to_string(), "{id}");
}
}
#[test]
fn command_scripts_and_batches_cannot_open_script_panels() {
for (id, params) in panel_commands() {
let store = Arc::new(MemoryConfig::default());
let mut s = session_with_panel(&store);
let e = s.execute("file.runScript", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string();
assert!(e.contains("scripts can't run scripts"), "{id}: {e}");
let e = s.execute("engine.batch", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string();
assert!(e.contains("can't run inside a batch"), "{id}: {e}");
assert!(!panel_open(&s), "{id}");
assert_eq!(setting(&mut s, "order"), "", "{id}");
}
}
#[test]
fn the_app_still_reopens_script_panels_at_launch() {
let store = Arc::new(MemoryConfig::default());
let mut s = session_with_panel(&store);
// As the desktop and web apps call it at launch.
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
assert_eq!(r, json!({"opened": ["Mark Panel.jsx"], "failed": []}));
assert!(panel_open(&s));
assert_eq!(setting(&mut s, "order"), "p");
}
#[test]
fn empty_or_missing_folders_run_nothing() {
let none = json!({"ran": [], "waiting": [], "failed": []});
let store = Arc::new(MemoryConfig::default());
let mut s = session(&store);
assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap(), none);
// A session without a settings folder.
let mut s = Session::default();
crate::install(&mut s);
assert_eq!(s.execute("file.runShutdownScripts", json!({})).unwrap(), none);
}
+10 -5
View File
@@ -404,8 +404,14 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv
// ---------------------------------------------------------------- scheduled tasks
/// Whether `host`'s tasks stop now that the project it ran with was closed: it has no window
/// open and is not a Startup script's.
fn ends_with_project(session: &Session, host: u32) -> bool {
!session.script_ui.windows.iter().any(|w| w.host == host) && !session.script_ui.startup_hosts.contains(&host)
}
/// Run the due tasks of the script-host threads; hosts without windows whose project was closed
/// end.
/// end, except Startup scripts' hosts.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
let epoch = session.script_ui.project_epoch;
@@ -413,8 +419,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr
if let Ok(mut h) = hosts().lock() {
for host in session.script_ui.task_hosts.clone() {
let Some(end) = h.get(&host) else { continue };
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
if windowless && end.epoch != epoch {
if end.epoch != epoch && ends_with_project(session, host) {
// Dropping its end lets the host thread finish.
stale.extend(h.remove(&host));
} else if end.next_due.is_some_and(|d| d <= now) {
@@ -441,7 +446,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr
}
/// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those
/// without windows whose project was closed stop.
/// without windows whose project was closed stop, except Startup scripts' hosts.
pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
for (host, name, epoch) in crate::tasks::hosts() {
if !session.script_ui.task_hosts.contains(&host) {
@@ -449,7 +454,7 @@ pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crat
}
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host));
if !alive || (windowless && epoch != session.script_ui.project_epoch) {
if !alive || (epoch != session.script_ui.project_epoch && ends_with_project(session, host)) {
crate::tasks::drop_host(host);
if host != CONSOLE_HOST && windowless {
drop_inline(host);
+56 -1
View File
@@ -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.
+2
View File
@@ -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");
+3
View File
@@ -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;
}
+62 -1
View File
@@ -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 [
@@ -7705,7 +7766,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...");
}
+7
View File
@@ -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", "没有嵌套合成"),
@@ -1977,6 +1979,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", "沒有嵌套合成"),
@@ -2747,6 +2750,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", "Немає вкладених композицій"),
@@ -3282,6 +3286,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"),
@@ -4043,6 +4048,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í"),
@@ -4627,6 +4633,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"),
+6 -1
View File
@@ -179,7 +179,12 @@ fn menu_nodes(
"recentScripts" => app.session.prefs.recent_scripts.get(i).map(|r| r.text().to_string()),
_ => None,
};
let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())));
let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str()));
// File ▸ Scripts entries show the key a script was given.
if let Some(s) = entry_shortcut(app, e) {
b = b.shortcut_text(shortcut_text(&s));
}
let r = ui.add_enabled(entry_enabled(app, e), b);
let r = match &tip {
Some(t) => r.on_hover_text(t),
None => r,
+27 -3
View File
@@ -196,8 +196,9 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
label: e.label.clone(),
command: e.command.clone(),
params: e.params.clone(),
shortcut: None,
accelerator: None,
// File ▸ Scripts entries show the key a script was given.
shortcut: crate::menus::entry_shortcut(app, e),
accelerator: crate::menus::entry_shortcut(app, e).as_deref().and_then(accelerator),
enabled: crate::menus::entry_enabled(app, e),
checked: crate::menus::entry_checked(app, e),
}));
@@ -360,7 +361,12 @@ pub fn state_key(app: &EffectcraftApp) -> u64 {
s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h));
s.state.open_comps.hash(&mut h);
s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h);
s.shortcuts().keys.len().hash(&mut h);
// Each binding's keys: a key changed to another one gives the item a new accelerator.
let shortcuts = s.shortcuts();
for b in &shortcuts.bindables {
b.key.hash(&mut h);
shortcuts.keys.get(&b.key).hash(&mut h);
}
s.keymaps.active.hash(&mut h);
let v = &app.ui.viewer;
(v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h);
@@ -722,6 +728,24 @@ mod tests {
assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj"));
}
/// A script's key changed to another key changes the key, so the File ▸ Scripts entry gets
/// the new accelerator even though the number of bindings stays the same.
#[test]
fn a_changed_shortcut_changes_the_state_key() {
let mut app = app();
let script = json!({"name": "Rename Layers.jsx"});
let b = app.session.shortcuts().find(&effectcraft_engine::shortcuts::binding_key("file.runScript", &script)).cloned().unwrap();
app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F7".to_string()]).unwrap();
app.session.shortcuts_changed();
let k0 = state_key(&app);
app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F8".to_string()]).unwrap();
app.session.shortcuts_changed();
assert_ne!(state_key(&app), k0);
let spec = build(&app);
let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).unwrap();
assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F8"));
}
/// #1630: a checked Window menu entry closes its panel; an unchecked one opens it.
#[test]
fn window_menu_entry_toggles_its_panel() {
+1 -1
View File
@@ -68,7 +68,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid));
let tex = match cached {
Some((k, tex, size)) if k == key => Some((tex, size)),
_ => app.session.footage.frame(view.item, f, view.time).map(|img| {
_ => effectcraft_engine::commands::footage_panel::frame(&app.session, view.item, f, view.time).map(|img| {
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8());
let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height])));
+130 -4
View File
@@ -233,7 +233,7 @@ fn new_comp_from_selection_form(s: &effectcraft_engine::Session) -> (String, Vec
}
/// The Interpret Footage dialog for the selected footage (or its proxy), if there is one.
fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec<Field>)> {
fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &mut Value) -> Option<(String, Vec<Field>)> {
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
(_, Some(px)) if proxy => Some(px.footage.clone()),
(effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
@@ -286,6 +286,30 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Optio
Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
];
// Color ▸ Cineon Settings (DPX and Cineon files). The converted points show in the
// footage's Units, and `pointUnits` reads them back in those units when Units changes.
if f.is_cineon_file() {
use effectcraft_engine::project::CineonUnits;
let c = f.cineon;
let units: Vec<(&str, Value)> = CineonUnits::ALL.iter().map(|u| (u.label(), json!(u.id()))).collect();
fields.extend([
Field::note("cineonSettings", "Cineon Settings"),
Field::bool("logConversion", "Logarithmic Conversion", c.log_conversion),
Field::num("convertedBlack", "Converted Black Point", c.units.from_ten_bit(f64::from(c.converted_black))),
Field::num("convertedWhite", "Converted White Point", c.units.from_ten_bit(f64::from(c.converted_white))),
Field::num("tenBitBlack", "10 Bit Black Point", f64::from(c.black)),
Field::num("tenBitWhite", "10 Bit White Point", f64::from(c.white)),
Field::num("currentGamma", "Current Gamma", f64::from(c.gamma)),
Field::num("highlightRolloff", "Highlight Rolloff", f64::from(c.rolloff)),
Field::choice("cineonUnits", "Units", &units, CineonUnits::ALL.iter().position(|u| *u == c.units).unwrap_or(0)),
]);
if !base.is_object() {
*base = json!({});
}
if let Some(m) = base.as_object_mut() {
m.insert("pointUnits".into(), json!(c.units.id()));
}
}
// After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
// After Effects), then Start Timecode.
let mut timing = Vec::new();
@@ -487,7 +511,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let layer = comp.and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l)));
let t = s.time();
let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t);
let base = p.clone();
let mut base = p.clone();
let (title, fields): (String, Vec<Field>) = match id {
// Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped
// files start at Settings ▸ Import ▸ Default Drag Import As.
@@ -651,10 +675,33 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
],
),
"file.interpretFootage" | "file.interpretProxy"
if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) =>
if !has(
p,
&[
"frameRate",
"alpha",
"loop",
"pixelAspect",
"colorProfile",
"fields",
"invertAlpha",
"matteColor",
"linearLight",
"startFrame",
"logConversion",
"cineonUnits",
"pointUnits",
"convertedBlack",
"convertedWhite",
"tenBitBlack",
"tenBitWhite",
"currentGamma",
"highlightRolloff",
],
) =>
{
let proxy = id == "file.interpretProxy";
let Some(form) = interpret_footage_form(s, proxy) else { return false };
let Some(form) = interpret_footage_form(s, proxy, &mut base) else { return false };
form
}
"file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s),
@@ -1074,4 +1121,83 @@ mod tests {
assert_eq!(r["path"], path.as_str());
assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml"));
}
#[test]
fn interpretation_menu_applies_explicit_cineon_parameters_without_a_dialog() {
use effectcraft_engine::project::{Footage, FootageKind, ItemKind, Proxy};
for command in ["file.interpretFootage", "file.interpretProxy"] {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let footage = Footage { path: "/m/scan.dpx".into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() };
let project = std::sync::Arc::make_mut(&mut app.session.project);
let item = project.add_item("scan.dpx", Default::default(), None, ItemKind::Footage(footage.clone()));
project.item_mut(item).unwrap().proxy = Some(Box::new(Proxy { footage, enabled: true }));
app.session.state.project_selection = vec![item];
let ctx = egui::Context::default();
for params in [
json!({"logConversion": true}),
json!({"cineonUnits": "10bit"}),
json!({"pointUnits": "10bit"}),
json!({"convertedBlack": 10.0}),
json!({"convertedWhite": 900.0}),
json!({"tenBitBlack": 90.0}),
json!({"tenBitWhite": 690.0}),
json!({"currentGamma": 2.2}),
json!({"highlightRolloff": 30.0}),
] {
crate::menus::invoke(&mut app, &ctx, command, params.clone()).unwrap();
assert!(app.dialog.is_none(), "{command}: {params}");
}
let item = app.session.project.item(item).unwrap();
let footage = if command == "file.interpretProxy" {
&item.proxy.as_ref().unwrap().footage
} else {
let ItemKind::Footage(footage) = &item.kind else { panic!("not footage") };
footage
};
assert!(footage.cineon.log_conversion);
assert_eq!(footage.cineon.gamma, 2.2);
assert_eq!(footage.cineon.converted_white, 900.0);
}
}
/// Interpret Footage shows Cineon Settings for DPX footage only. The converted points show in
/// the footage's Units, and choosing other Units reads the shown values in the old ones.
#[test]
fn interpret_footage_shows_cineon_settings_for_dpx() {
use effectcraft_engine::project::{CineonUnits, Footage, FootageKind, ItemKind};
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let still =
|path: &str| ItemKind::Footage(Footage { path: path.into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() });
let project = std::sync::Arc::make_mut(&mut app.session.project);
let dpx = project.add_item("scan.dpx", Default::default(), None, still("/m/scan.dpx"));
let png = project.add_item("still.png", Default::default(), None, still("/m/still.png"));
let keys = |app: &EffectcraftApp| app.dialog_state.form.fields.iter().map(|f| f.key.clone()).collect::<Vec<_>>();
app.session.state.project_selection = vec![png];
assert!(open_form(&mut app, "file.interpretFootage", &json!({})));
assert!(!keys(&app).iter().any(|k| k == "logConversion"), "{:?}", keys(&app));
app.session.state.project_selection = vec![dpx];
assert!(open_form(&mut app, "file.interpretFootage", &json!({})));
for k in ["logConversion", "convertedBlack", "convertedWhite", "tenBitBlack", "tenBitWhite", "currentGamma", "highlightRolloff", "cineonUnits"] {
assert!(keys(&app).iter().any(|x| x == k), "{k}: {:?}", keys(&app));
}
// Units ▸ 8-bit with Converted White Point still showing 1023 (10-bit).
let form = &mut app.dialog_state.form;
for f in form.fields.iter_mut() {
match (f.key.as_str(), &mut f.kind) {
("cineonUnits", FieldKind::Choice { sel, .. }) => *sel = 1,
("logConversion", FieldKind::Bool(b)) => *b = true,
_ => {}
}
}
let params = form.params();
assert_eq!((params["pointUnits"].as_str(), params["cineonUnits"].as_str()), (Some("10bit"), Some("8bit")), "{params}");
app.session.execute("file.interpretFootage", params).unwrap();
let Some(ItemKind::Footage(f)) = app.session.project.item(dpx).map(|i| &i.kind) else { panic!("not footage") };
assert!(f.cineon.log_conversion && f.cineon.units == CineonUnits::EightBit, "{:?}", f.cineon);
assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon);
// Opened again, the point shows in 8-bit units.
assert!(open_form(&mut app, "file.interpretFootage", &json!({})));
let white = app.dialog_state.form.fields.iter().find(|f| f.key == "convertedWhite").map(|f| f.kind.clone());
assert!(matches!(white, Some(FieldKind::Number { value, .. }) if (value - 255.0).abs() < 1e-6), "{white:?}");
}
}
+16 -4
View File
@@ -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);
+44 -2
View File
@@ -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");
}
}
@@ -49,6 +49,8 @@ const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c);
pub fn open(app: &mut EffectcraftApp) {
app.dialog_state.shortcuts = EditorState::default();
// Scripts copied into the Scripts folder since the table was built are listed too.
app.session.scripts_changed();
app.dialog = Some(Dialog::Shortcuts);
}
+20
View File
@@ -654,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");
@@ -724,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;
+4
View File
@@ -4111,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!({})),
@@ -4203,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);
@@ -5128,6 +5131,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
("trim", "Trim Paths"),
("twist", "Twist"),
("wiggle", "Wiggle Paths"),
("wiggler", "Wiggle Transform"),
("zigzag", "Zig Zag"),
];
+27 -1
View File
@@ -16,7 +16,16 @@ use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Commands that replace or close the open project (Quit is asked when the window closes).
const CLOSING: &[&str] = &["file.newProject", "file.open", "file.openRecent", "file.closeProject", "file.revert", "file.openDemoProject", "templates.create"];
const CLOSING: &[&str] = &[
"file.newProject",
"file.open",
"file.openRecent",
"file.closeProject",
"file.revert",
"file.openDemoProject",
"templates.create",
"file.runShutdownScripts",
];
/// The command waiting on the prompt, and whether the user already answered it.
#[derive(Clone, Debug, Default)]
@@ -239,6 +248,23 @@ mod tests {
assert!(app.session.is_dirty());
}
#[test]
fn shutdown_scripts_prompt_before_discarding_unsaved_work() {
let (mut app, ctx) = app();
modified(&mut app);
let result = crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap();
assert_eq!(result, json!({"dialog": "unsavedChanges"}));
assert_eq!(app.session.project.comps().count(), 1);
assert!(app.session.is_dirty());
answer_with(&mut app, &ctx, Answer::Cancel);
assert_eq!(app.session.project.comps().count(), 1);
assert!(app.session.is_dirty());
crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap();
answer_with(&mut app, &ctx, Answer::Discard);
assert_eq!(app.session.project.comps().count(), 0);
assert!(!app.session.is_dirty() && app.dialog.is_none());
}
#[test]
fn closing_a_modified_project_asks_and_cancel_keeps_it() {
let (mut app, ctx) = app();
+1 -1
View File
@@ -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, crate::i18n::tr("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,
+25 -20
View File
@@ -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 });
+21
View File
@@ -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
+21
View File
@@ -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<_>>());
}
+36
View File
@@ -113,6 +113,42 @@ fn panels_open_at_quit_dock_again_at_launch() {
assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#));
}
/// A script copied into the Scripts folder while the app runs is listed in Edit ▸ Keyboard
/// Shortcuts and takes a key there: File ▸ Scripts shows the key next to the script, and
/// pressing it runs the script as its menu entry does.
#[test]
fn a_key_given_to_a_script_runs_it() {
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
let store = std::sync::Arc::new(MemoryConfig::default());
let mut s = effectcraft_host::session();
s.config = Some(store.clone());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
assert!(h.state().session.shortcuts().find(r#"file.runScript {"name":"Rename Layers.jsx"}"#).is_some(), "the shortcut table is built");
store.write("Scripts/Add Comp.jsx", "app.project.items.addComp('From Key', 64, 64, 1, 1, 25);").unwrap();
effectcraft_ui_egui::panels::shortcut_editor::open(h.state_mut());
let script = json!({"name": "Add Comp.jsx"});
let r = h.state_mut().session.execute("shortcuts.set", json!({"command": "file.runScript", "params": script, "keys": "Cmd+Alt+Shift+F7"})).unwrap();
assert_eq!(r["command"], r#"file.runScript {"name":"Add Comp.jsx"}"#, "{r}");
assert_eq!(r["keys"], json!(["Cmd+Alt+Shift+F7"]), "{r}");
assert_eq!(r["conflicts"], json!([]), "{r}");
h.state_mut().dialog = None;
let spec = effectcraft_ui_egui::native_menu::build(h.state());
let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).expect("the File ▸ Scripts entry");
assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F7"));
let (m, k, ..) = effectcraft_ui_egui::menus::bindings(&h.state().session)
.into_iter()
.find(|b| b.2 == "file.runScript" && b.3 == script)
.expect("the key is bound to the script");
let made = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name == "From Key").count();
assert_eq!(made(&h), 0);
for pressed in [true, false] {
h.event(Event::Key { key: k, physical_key: Some(k), pressed, repeat: false, modifiers: m });
h.step();
}
assert_eq!(made(&h), 1, "the key runs the script");
}
/// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a
/// preset in its list eases the selected keyframes in one undo step.
#[test]
@@ -76,6 +76,22 @@ fn shape_contents_add_menu_adds_path_operations() {
assert_eq!(items, ["group", "trim"]);
}
#[test]
fn shape_contents_add_menu_adds_wiggle_transform() {
let mut s = session();
let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap();
let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid;
let mut h = harness(s);
open_layer(&mut h, l);
click(&mut h, &format!("timeline.group.{contents}.add"));
assert!(h.get_by_label("Zig Zag").rect().max.y <= 1000.0, "the whole menu fits in the window");
let entry = h.get_by_label("Wiggle Transform").rect();
click_at(&mut h, entry.center());
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
assert_eq!(items, ["group", "wiggler"]);
}
/// A shape group's inner Contents is elided in After Effects: opening the group lists its items
/// (here the path) directly, with no "Contents" row between them.
#[test]
+3 -1
View File
@@ -583,7 +583,9 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl
`"waiting": true`, and the script continues (its final output arrives in the reply of the click
that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`,
`file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`,
`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`:
`window.restoreScriptPanels` (the panels open when the app last quit), `file.runStartupScripts` and
`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes
the project first). Scripts' `app.settings`:
`script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`.
Extension points and the bundled extensions: [plugins.md](plugins.md).
+1 -1
View File
@@ -49,7 +49,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| BMP | R | yes | — | good |
| WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects |
| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | yes (sequence; uncompressed 24-bit RGB or 32-bit RGB + Alpha, straight or premultiplied) | good: import (#745); output reads back through it unchanged; the alpha is not labelled in the file (no TGA 2.0 extension area), so it is interpreted on import |
| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX |
| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX |
| Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output |
| SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) |
| Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared |
+7 -2
View File
@@ -94,16 +94,21 @@ used, and layers that ran to the end of the footage keep doing so.
| Fields, pixel aspect, loop | As in After Effects. |
| **Assign Profile**, **Interpret As Linear Light** | The colour profile the file's values are in (the working space converts from it), and whether they are linear light. |
| **Preserve RGB** | The file's values enter the composition unconverted: no colour profile, linear light or working-space conversion, and float files (OpenEXR, float TIFF) keep their linear values instead of being sRGB-encoded. Use it to convert chosen layers yourself with an OCIO effect, or to read data passes (depth, mist, normals) as numbers. |
| **Cineon Settings** (DPX and Cineon files) | With *Logarithmic Conversion* on, the file's log code values are converted to the *Current Gamma* (1.7 by default; 1 gives linear light): the *10 Bit Black Point* (95) becomes the *Converted Black Point* (0), and with *Highlight Rolloff* at 0 the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) bends the top 20% of the converted range into a curve, as the Cineon Converter effect does: the white point then converts to a level below the Converted White Point, and brighter code values stay below it. At 0, a 32 bpc project keeps over-range values. *Units* shows the converted points as 10-bit, 8-bit or float values. With Logarithmic Conversion off (the default), the code values are shown as stored. |
Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers
when a sequence imports; Interpret Footage lists them too.
**File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha,
fields, pixel aspect, loop and colour settings (Preserve RGB too) to other footage.
fields, pixel aspect, loop and colour settings (Preserve RGB too, and Cineon Settings between
DPX and Cineon files) to other footage.
Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied",
"startTimecode": 1001, "preserveRgb": true}` (timecode or a frame number; `"overrideStart":
false` goes back to Use Source File Timecode).
false` goes back to Use Source File Timecode). Cineon Settings: `{"logConversion": true,
"tenBitBlack": 95, "tenBitWhite": 685, "currentGamma": 1.7, "highlightRolloff": 20,
"cineonUnits": "8bit", "convertedBlack": 0, "convertedWhite": 255}` (converted points in
`cineonUnits`, else the footage's Units).
## Multi-layer OpenEXR
+8 -5
View File
@@ -37,7 +37,7 @@ because 179 issues were closed with regression tests between 5 and 10 October.
| UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) |
| File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) |
| Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) |
| Localization | ≈ 18% | 60–110 + review | [localization-parity.md](localization-parity.md) |
| Localization | ≈ 24% | 60–110 + review | [localization-parity.md](localization-parity.md) |
| Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed), and no CI workflow runs `cargo xtask ci` on pull requests |
| Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October (as counted on 10 October), nearly all with regression tests ([log below](#real-user-report-log-510-october)); 216 open and 328 closed on 12 October, many from Linux and Windows. Most checking still happens on macOS |
| Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304; the RAM preview now prefetches nearest the playhead, 271000b2), late audio (#504, #335); slow scrubbing (#329, closed by #784) and #554 are closed. No 4K / hundreds-of-layers project tested |
@@ -72,8 +72,8 @@ are grouped by area; the issue tracker has the rest.
| 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) |
| 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) |
| 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) |
| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) |
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 630 rows each, 629 in Georgian); panels in 7 (ja 996 rows, zh-hans 998, zh-hant 951, uk 524, pt-br 695, cs 518, ka 885); effect names translated in Simplified Chinese (307), Japanese (297) and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) |
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
| 20 | **Multi-monitor and video output** | #486: on Windows, macOS and X11 every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen (on Wayland, the web and in embedded viewports floating panels stay in the app window, since Wayland reports no window positions); tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
@@ -310,8 +310,11 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins
([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI
panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel
and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard
shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759,
and open again at the next launch. The scripts in the Startup folder run at launch, and the desktop
app runs the Shutdown folder's scripts as it quits. Each File ▸ Scripts entry can be given a key in
Edit ▸ Keyboard Shortcuts. Missing: a global scope that scripts share for the session (a Startup
script's variables and functions are not available to later scripts) and `.jsxbin`;
`#include` / `#target` directives landed (#759,
[scripting-parity.md](scripting-parity.md)).
- Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept
by name and applied to pairs of keyframes. Modelled on a third-party panel, they are not core: a
+6 -2
View File
@@ -16,9 +16,9 @@ Guide):
| File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) |
| File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same |
| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same |
| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap |
| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | `Scripts/Startup/` and `Scripts/Shutdown/` in the settings folder, in alphabetical order (case ignored): the Startup scripts run at launch before a project opens, and the Shutdown scripts run as the desktop app quits, after the project is closed. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next script still runs | Partial: each script runs in its own global scope, so variables and functions a Startup script defines are not available to later scripts; a Startup script's modal dialog stays open while the next scripts run and the app starts; a Shutdown script's modal dialogs close as if canceled; the web app runs no Shutdown scripts |
| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap |
| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts under File ▸ Scripts (`file.runScript {"name": "<file name>"}`); a key given to one is saved with the preset, File ▸ Scripts shows it, and pressing it runs the script as its menu entry does | Same. After Effects binds a key to a script's place in the alphabetical list (`ExecuteScriptMenuItem01`…), and EffectCraft binds it to the script's file name. The Window menu entries of ScriptUI panels aren't offered |
| Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points |
| `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same |
| Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same |
@@ -39,6 +39,10 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs
file.jsx`, or `script.run {code, name}` over the control channel and MCP.
* Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings
folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart).
* Run one at every launch or quit: copy it into `Scripts/Startup/` or `Scripts/Shutdown/` in the
settings folder. These scripts are not listed in File ▸ Scripts. `file.runStartupScripts` and
`file.runShutdownScripts` run them and return `{ran, waiting, failed}`; the desktop app calls them at
launch and quit, and the web app calls `file.runStartupScripts` at launch.
* `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs
one; `file.uninstallScript {name}` removes an installed one.
* A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts
+2 -1
View File
@@ -190,7 +190,8 @@ Effect Controls uses compact, fixed 20 pt parameter rows, as After Effects does:
Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing
which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific,
both: split), modifier toggles, a searchable list of every command (all engine commands, menu
entries with bound parameters and the frontend's tool / panel commands), and a preset menu.
entries with bound parameters, the scripts File ▸ Scripts lists and the frontend's tool / panel
commands), and a preset menu.
- **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing
it creates a "Custom" copy. Custom presets store only their differences from the default and
+4 -4
View File
@@ -21,12 +21,12 @@ our own scripts; no real-world script library has been run against both apps.
| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) |
| `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging |
| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` |
| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) |
| Startup / Shutdown folders | yes | not run | missing | plugins.md |
| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) |
| `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths |
| `.jsxbin` | yes | no | out of scope | No public specification |
| Keyboard shortcuts for scripts | yes | no | missing | plugins.md |
| Keyboard shortcuts for scripts | yes: the scripts of the Scripts folder take keys, by their place in the alphabetical list | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts; a key stays with the script's file name | same | `file_scripts_entries_take_keyboard_shortcuts`, `a_key_given_to_a_script_runs_it` ([plugins.md](plugins.md)); ScriptUI panels in the Window menu take no key |
| Recent Script Files | File ▸ Scripts ▸ Recent Script Files (10 scripts), a key to rerun | Same list, kept in the settings; Cmd+Alt+Shift+D reruns the most recent script | same | `recent_script_files_run_again` ([plugins.md](plugins.md)) |
| Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` |
| Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — |
@@ -35,7 +35,7 @@ our own scripts; no real-world script library has been run against both apps.
## Estimate
**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown remain), script shortcuts 2–4 h,
**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown landed; a global scope shared between scripts remains), script shortcuts 2–4 h,
a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression
performance benchmark 4–8 h.
+1 -1
View File
@@ -117,7 +117,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for
| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed |
| Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed |
| Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) |
| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) |
| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); external editor (#364); see [scripting-parity.md](scripting-parity.md) |
| Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) |
| Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) |
| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 76 built-in presets (text, Transitions, Backgrounds, Behaviors) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) |