mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # crates/ui-egui/src/panels/viewer/frame.rs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let repeat = ctx.params.b("repeatEdge");
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad((s * 3.0).ceil() as u32);
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(ctx.layer_size);
|
||||
}
|
||||
let (sx, sy) = (s * kx, s * ky);
|
||||
// Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939).
|
||||
@@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if !repeat && !ctx.adjustment {
|
||||
let reach = if frac > 0.0 { r + 1 } else { r };
|
||||
b.pad((reach * it) as u32 + 1);
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(ctx.layer_size);
|
||||
}
|
||||
let edge = repeat || ctx.adjustment;
|
||||
let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge);
|
||||
|
||||
@@ -97,6 +97,21 @@ fn bubbles_rise() {
|
||||
assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}");
|
||||
}
|
||||
|
||||
/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands
|
||||
/// in for transparent ones.
|
||||
#[test]
|
||||
fn bubbles_near_edges_show_the_layer() {
|
||||
let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]);
|
||||
let pale = [0, 1].map(|reflection| {
|
||||
let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))];
|
||||
let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3);
|
||||
assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
|
||||
// Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha.
|
||||
a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count()
|
||||
});
|
||||
assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drizzle_ripples() {
|
||||
let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6);
|
||||
|
||||
@@ -182,7 +182,7 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
// Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror).
|
||||
let k = if metal { 1.6 } else { -0.7 };
|
||||
let nz = (1.0 - d2).max(0.0).sqrt();
|
||||
let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
|
||||
let p = src.sample_bilinear_clamped((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
|
||||
let (c, sa) = unpremul(p);
|
||||
let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c };
|
||||
let mut a = cov;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms);
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", ¶ms),
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -160,6 +160,37 @@ fn psd_imports_as_composition() {
|
||||
assert!(s.project.comp(cid).is_none());
|
||||
}
|
||||
|
||||
/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that
|
||||
/// composition; it isn't wrapped in another.
|
||||
#[test]
|
||||
fn psd_imported_as_composition_with_new_comp_is_the_new_comp() {
|
||||
let path = tmp("newcomp.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
|
||||
assert_eq!(s.state.active_comp, Some(cid));
|
||||
let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count();
|
||||
assert_eq!(roots, 1, "no wrapper comp");
|
||||
}
|
||||
|
||||
/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds
|
||||
/// its composition once, not its folder too.
|
||||
#[test]
|
||||
fn psd_imported_as_composition_into_a_comp_adds_one_layer() {
|
||||
let path = tmp("dropped.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let psd = r["comps"][0].as_u64().unwrap();
|
||||
let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers;
|
||||
assert_eq!(layers.len(), 1, "{layers:?}");
|
||||
assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_retain_layer_sizes() {
|
||||
let path = tmp("retain.psd");
|
||||
|
||||
+83
-14
@@ -31,6 +31,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
||||
__seed = 0;
|
||||
__timeless = false;
|
||||
__projectInfo = null;
|
||||
__layersTrim();
|
||||
}
|
||||
|
||||
function __finish(r) {
|
||||
@@ -44,7 +45,7 @@ function __finish(r) {
|
||||
|
||||
function __result(r) {
|
||||
r = __v(r);
|
||||
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
|
||||
if (typeof r === 'function' && r.__isLayer) return r.index;
|
||||
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();
|
||||
// `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while
|
||||
// host requests can still be answered and the script re-run.
|
||||
@@ -488,7 +489,7 @@ function __ancestor(c, l, path, countUp) {
|
||||
if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more');
|
||||
n = Math.floor(n);
|
||||
var info = __h(__ANCESTOR, c, l, path, n);
|
||||
if (info === 'layer') return new Layer(c, l);
|
||||
if (info === 'layer') return __layer(c, l);
|
||||
if (info === null) __err('propertyGroup(' + n + ') goes above the layer');
|
||||
return __mkGroup(c, l, info);
|
||||
}
|
||||
@@ -518,10 +519,10 @@ Comp.prototype = {
|
||||
return __err('Composition "' + this.name + '" has no active camera');
|
||||
},
|
||||
layer: function (a, rel) {
|
||||
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
|
||||
if (typeof a === 'function' && a.__isLayer) return this.layer(a.index + (rel || 0));
|
||||
var id = __h(__LAYER, this.__c, a);
|
||||
if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range');
|
||||
return new Layer(this.__c, id);
|
||||
return __layer(this.__c, id);
|
||||
},
|
||||
toString: function () { return '[object Comp]'; },
|
||||
};
|
||||
@@ -596,7 +597,55 @@ var thisProject = {
|
||||
|
||||
// Layer -----------------------------------------------------------------------------------------
|
||||
|
||||
function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; }
|
||||
// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another layer's
|
||||
// `("effects")("Slider Control")`, `thisLayer(1)` (what the pick whip writes with Compact English
|
||||
// off). Callable like a group (`__mkGroup`), with the Layer methods.
|
||||
function Layer(c, l) {
|
||||
var f = function (k) { return f.__group(k); };
|
||||
delete f.name;
|
||||
delete f.length;
|
||||
Object.setPrototypeOf(f, Layer.prototype);
|
||||
// Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete
|
||||
// a field or leave its own on it. Its info is private to `__info`, which reads it again in an
|
||||
// evaluation whose `__S` isn't the one it was read in (every evaluation has a new `__S`), so
|
||||
// freezing the layer can't keep its info either.
|
||||
var info = null, read = null;
|
||||
Object.defineProperty(f, '__c', { value: c });
|
||||
Object.defineProperty(f, '__l', { value: l });
|
||||
Object.defineProperty(f, '__info', {
|
||||
get: function () {
|
||||
if (read !== __S) { info = __h(__LINFO, c, l); read = __S; }
|
||||
return info;
|
||||
},
|
||||
});
|
||||
Object.preventExtensions(f);
|
||||
return f;
|
||||
}
|
||||
|
||||
// Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain
|
||||
// object, so it is kept, and its info (`__info`) is read again in each evaluation.
|
||||
// The kept layers are out of an expression's reach (their maps have no prototype, so a change to
|
||||
// `Object.prototype` doesn't reach them either), and bounded like the runtime's scripts: once
|
||||
// `max` are kept, the layers after them aren't, and the next evaluation starts over
|
||||
// (`__layersTrim`, so a layer stays the same object for a whole evaluation).
|
||||
var __layer, __layersTrim;
|
||||
(function () {
|
||||
var layers = Object.create(null), kept = 0, max = 4096;
|
||||
__layer = function (c, l) {
|
||||
// By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves.
|
||||
var m = layers[c], f = m && m[l];
|
||||
if (f) return f;
|
||||
f = new Layer(c, l);
|
||||
if (kept < max) {
|
||||
(m || (layers[c] = Object.create(null)))[l] = f;
|
||||
kept++;
|
||||
}
|
||||
return f;
|
||||
};
|
||||
__layersTrim = function () {
|
||||
if (kept >= max) { layers = Object.create(null); kept = 0; }
|
||||
};
|
||||
})();
|
||||
|
||||
function __m3(m, p) {
|
||||
var x = p[0] || 0, y = p[1] || 0;
|
||||
@@ -620,7 +669,6 @@ function __m4(m, p, vec) {
|
||||
|
||||
Layer.prototype = {
|
||||
__isLayer: true,
|
||||
get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); },
|
||||
get name() { return this.__info[0]; },
|
||||
get index() { return this.__info[1]; },
|
||||
get inPoint() { return this.__info[2]; },
|
||||
@@ -632,7 +680,7 @@ Layer.prototype = {
|
||||
get parent() {
|
||||
var p = this.__info[7];
|
||||
if (p === null) __err('Layer "' + this.name + '" has no parent');
|
||||
return new Layer(this.__c, p);
|
||||
return __layer(this.__c, p);
|
||||
},
|
||||
get hasVideo() { return this.__info[8]; },
|
||||
get enabled() { return this.__info[11]; },
|
||||
@@ -804,7 +852,7 @@ Prop.prototype = {
|
||||
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
|
||||
__wrap: function (v) {
|
||||
var kind = this.__info[2];
|
||||
if (kind === 'layer') return v === null ? null : new Layer(this.__c, v);
|
||||
if (kind === 'layer') return v === null ? null : __layer(this.__c, v);
|
||||
if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]);
|
||||
return v;
|
||||
},
|
||||
@@ -834,7 +882,7 @@ Prop.prototype = {
|
||||
for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
|
||||
return this.key(best + 1);
|
||||
},
|
||||
__seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; },
|
||||
__seedIndex: function () { return this.__self ? __S.idx : __layer(this.__c, this.__l).index; },
|
||||
wiggle: function (freq, amp, octaves, ampMult, t) {
|
||||
if (octaves === undefined) octaves = 1;
|
||||
if (ampMult === undefined) ampMult = 0.5;
|
||||
@@ -903,8 +951,9 @@ var __propHandler = {
|
||||
var v = t.value;
|
||||
if (v === null || v === undefined) return undefined;
|
||||
var x = v[k];
|
||||
// Property groups are callable proxies: hand them over as they are, not bound like methods (#809).
|
||||
if (x?.__isGroup) return x;
|
||||
// Property groups and layers are callable: hand them over as they are, not bound like methods
|
||||
// (#809).
|
||||
if (typeof x === 'function' && (x.__isGroup || x.__isLayer)) return x;
|
||||
return typeof x === 'function' ? x.bind(v) : x;
|
||||
},
|
||||
};
|
||||
@@ -1082,18 +1131,18 @@ Markers.prototype = {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're
|
||||
// accessors on the global object; assigning one (`var width = 10`) shadows it for the current
|
||||
// evaluation only.
|
||||
// evaluation only. They can't be deleted or redefined, so an expression can't take one away from
|
||||
// the evaluations after it.
|
||||
|
||||
function __global(name, get) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
get: function () { return name in __S.vars ? __S.vars[name] : get(); },
|
||||
set: function (v) { __S.vars[name] = v; },
|
||||
});
|
||||
}
|
||||
|
||||
__global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); });
|
||||
__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); });
|
||||
__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); });
|
||||
__global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); });
|
||||
['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active',
|
||||
'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) {
|
||||
@@ -1226,3 +1275,23 @@ function nearestKey(t) { return thisProperty.nearestKey(t); }
|
||||
delete globalThis.Proxy;
|
||||
delete globalThis.Reflect;
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The runtime runs this file once and keeps it for the thread's later evaluations, so an
|
||||
// expression can't change the object model or the internals: the prototypes are frozen, and the
|
||||
// `__` globals, the constructors and `thisProject` can't be reassigned. The `__` state below is
|
||||
// set again by `__begin` in each evaluation.
|
||||
|
||||
[Comp, Footage, Layer, Prop, Markers, __Path, __TextStyle].forEach(function (f) {
|
||||
Object.freeze(f.prototype);
|
||||
Object.freeze(f);
|
||||
});
|
||||
[__GroupProto, __groupHandler, __propHandler, thisProject].forEach(Object.freeze);
|
||||
(function () {
|
||||
var perEvaluation = { __S: true, __rc: true, __seed: true, __timeless: true, __projectInfo: true };
|
||||
var model = { Comp: true, Footage: true, Layer: true, Prop: true, Markers: true, thisProject: true };
|
||||
Object.getOwnPropertyNames(globalThis).forEach(function (k) {
|
||||
if (!(k.slice(0, 2) === '__' && !perEvaluation[k]) && !model[k]) return;
|
||||
if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false });
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1099,6 +1099,8 @@ fn shadowing_layer_attribute_names_is_local() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0);
|
||||
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
|
||||
assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0);
|
||||
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1493,4 +1495,97 @@ fn time_to_frames_preserves_coercion_and_small_durations() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another
|
||||
/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact
|
||||
/// English off (they failed: "not a callable function"). It is still a layer.
|
||||
#[test]
|
||||
fn layers_are_callable_property_groups() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0);
|
||||
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0);
|
||||
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0);
|
||||
assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0);
|
||||
// Its own name, index and methods, and a layer as the result still reads as its index.
|
||||
assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0);
|
||||
assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0);
|
||||
assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0);
|
||||
assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")"));
|
||||
}
|
||||
|
||||
/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project
|
||||
/// afresh in each, and an expression's own fields on it don't reach the next one.
|
||||
#[test]
|
||||
fn a_kept_layer_reads_the_project_again() {
|
||||
let mut f = fx();
|
||||
let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length";
|
||||
assert_close!(f.num(first, 0.0), 21.0);
|
||||
f.layer_mut(f.b).name = "Bee".into();
|
||||
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
|
||||
assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0);
|
||||
assert_close!(f.num("thisLayer.index", 0.0), 2.0);
|
||||
let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0);
|
||||
assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0);
|
||||
let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0);
|
||||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
|
||||
let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0);
|
||||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
|
||||
let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0);
|
||||
f.layer_mut(f.a).name = "Ay".into();
|
||||
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
|
||||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0);
|
||||
}
|
||||
|
||||
/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more
|
||||
/// layers than are kept still sees one object for each layer it had kept, and the evaluations
|
||||
/// after it, which start over, read the right layers.
|
||||
#[test]
|
||||
fn the_kept_layers_are_bounded() {
|
||||
let f = fx();
|
||||
let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)";
|
||||
let many =
|
||||
format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)");
|
||||
assert_close!(f.num(&many, 0.0), 11.0);
|
||||
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
|
||||
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
|
||||
}
|
||||
|
||||
/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under
|
||||
/// the comp's id, before any of its layers is kept, doesn't stop them being kept.
|
||||
#[test]
|
||||
fn the_kept_layers_dont_go_through_object_prototype() {
|
||||
let f = fx();
|
||||
let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0);
|
||||
assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// The prelude is kept from one evaluation to the next, so an expression can't change its object
|
||||
/// model or internals for the ones after it.
|
||||
#[test]
|
||||
fn an_expression_cant_change_the_prelude_for_the_next() {
|
||||
let f = fx();
|
||||
let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]";
|
||||
let before = f.ev(f.a, ROT, read, 0.5).unwrap();
|
||||
let attacks = [
|
||||
"Comp.prototype.layer = function () { return 0; }",
|
||||
"Object.getPrototypeOf(thisLayer).index = 0",
|
||||
"Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }",
|
||||
"Object.getPrototypeOf(thisLayer.transform.opacity).value = 0",
|
||||
"Comp = function () {}",
|
||||
"Prop = function () {}",
|
||||
"thisProject = null",
|
||||
"__h = function () { return null; }",
|
||||
"__layer = function () { return null; }",
|
||||
"__child = function () {}",
|
||||
"delete __mkProp",
|
||||
"delete thisLayer",
|
||||
"delete thisComp",
|
||||
"delete width",
|
||||
"Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })",
|
||||
];
|
||||
for a in attacks {
|
||||
let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0);
|
||||
assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`");
|
||||
}
|
||||
}
|
||||
|
||||
mod property_group;
|
||||
|
||||
@@ -72,6 +72,9 @@ fn property_group_reaches_the_layer() {
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
// The layer it reaches is the same object as `thisLayer`.
|
||||
assert_close!(f.num("thisLayer('transform').propertyGroup(1) === thisLayer ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2) === thisLayer ? 1 : 0", 0.0), 1.0);
|
||||
let s = staggered();
|
||||
assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,20 @@ pub(crate) struct GBuf {
|
||||
}
|
||||
|
||||
impl GBuf {
|
||||
/// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it.
|
||||
pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> {
|
||||
let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) };
|
||||
if !e.g.fits(w, h) {
|
||||
return None;
|
||||
}
|
||||
let out = e.image(w, h);
|
||||
e.copy_into(&self.img, &out, dx, dy);
|
||||
self.img = out;
|
||||
self.offset[0] += dx as f64;
|
||||
self.offset[1] += dy as f64;
|
||||
Some(())
|
||||
}
|
||||
|
||||
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
|
||||
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -180,6 +180,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let size = (ctx.params.f("size") * b.scale).max(1.0);
|
||||
if ctx.params.b("resizeLayer") && !ctx.adjustment {
|
||||
b.pad(e, amount.abs().ceil() as u32 + 1)?;
|
||||
} else {
|
||||
// Room for pixels displaced past the content, up to the layer's edge (#1205).
|
||||
b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?;
|
||||
}
|
||||
let off = b.to_px(ctx.params.v2("offset"));
|
||||
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
|
||||
|
||||
@@ -180,7 +180,7 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
}
|
||||
let k = select(-0.7, 1.6, P.u[1].y != 0u);
|
||||
let nz = sqrt(max(1.0 - d2, 0.0));
|
||||
let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k));
|
||||
let s = fxm_unpremul(sample_bilinear_clamped(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k));
|
||||
var c = s.xyz;
|
||||
if (s.w <= 0.0) {
|
||||
c = vec3<f32>(0.6, 0.7, 0.8);
|
||||
|
||||
@@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat
|
||||
return Some(format!("blend mode {}", l.blend_mode.label()));
|
||||
}
|
||||
// Premiere's Basic Motion in the interchange formats has one (uniform) scale.
|
||||
if let Some(s) = l.transform().and_then(|t| t.get("scale"))
|
||||
&& std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() > 1e-9
|
||||
})
|
||||
{
|
||||
if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) {
|
||||
return Some("non-uniform scale".into());
|
||||
}
|
||||
if l.preserve_transparency {
|
||||
@@ -581,10 +576,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
if let Some(p) = tr.get("scale") {
|
||||
let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() < 1e-9
|
||||
});
|
||||
let uniform = !non_uniform_scale(p);
|
||||
m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1])));
|
||||
if !uniform {
|
||||
m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0])));
|
||||
@@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool {
|
||||
(a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6
|
||||
}
|
||||
|
||||
/// Whether a scale property is non-uniform at some point: active key values, or the static
|
||||
/// value when every key is muted (muted keys are not exported, #1894).
|
||||
fn non_uniform_scale(p: &Property) -> bool {
|
||||
let non_uniform = |v: &Value| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() > 1e-9
|
||||
};
|
||||
if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) }
|
||||
}
|
||||
|
||||
/// The clip's `id` effect, or a new instance of it (None if the registry has no such effect).
|
||||
fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option<fp::EffectInstance> {
|
||||
ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance()))
|
||||
@@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) {
|
||||
/// A property → a FilmCraft parameter (layer-time keys → clip-time keys).
|
||||
fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param {
|
||||
let mut out = fp::Param::new(conv(&p.value));
|
||||
for k in &p.keys {
|
||||
// XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so
|
||||
// the exported animation matches what the renderer shows (#1894).
|
||||
for k in p.keys.iter().filter(|k| !k.muted) {
|
||||
let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value));
|
||||
fk.interp = match k.out_interp {
|
||||
Interp::Linear => fp::Interpolation::Linear,
|
||||
|
||||
@@ -480,6 +480,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property {
|
||||
l.transform().unwrap().get("position").unwrap()
|
||||
}
|
||||
|
||||
/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe
|
||||
/// (the export would otherwise animate through it).
|
||||
#[test]
|
||||
fn export_skips_muted_keyframes() {
|
||||
let mut p = Project::default();
|
||||
let a = footage_item(&mut p, "/media/a.mov");
|
||||
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0));
|
||||
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
|
||||
{
|
||||
let pos = l.transform_mut().unwrap().get_mut("position").unwrap();
|
||||
pos.keys = vec![
|
||||
Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])),
|
||||
Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])),
|
||||
Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])),
|
||||
];
|
||||
pos.keys[1].muted = true;
|
||||
}
|
||||
c.layers.push(l);
|
||||
let cid = comp_item(&mut p, "Main", c);
|
||||
let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap();
|
||||
let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe);
|
||||
let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main");
|
||||
let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap();
|
||||
assert_eq!(pos.keys.len(), 2, "the muted key was exported");
|
||||
assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0);
|
||||
assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0);
|
||||
}
|
||||
|
||||
/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the
|
||||
/// active keys matter, and the static value when every key is muted.
|
||||
#[test]
|
||||
fn muted_non_uniform_scale_does_not_force_prerendering() {
|
||||
let mut p = Project::default();
|
||||
let a = footage_item(&mut p, "/media/a.mov");
|
||||
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0));
|
||||
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
|
||||
{
|
||||
let scale = l.transform_mut().unwrap().get_mut("scale").unwrap();
|
||||
scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))];
|
||||
scale.keys[1].muted = true;
|
||||
}
|
||||
let lid = l.id;
|
||||
c.layers.push(l);
|
||||
let cid = comp_item(&mut p, "Main", c);
|
||||
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None);
|
||||
// With every key muted the static value drives evaluation, so a non-uniform static scale
|
||||
// still takes the flattened path.
|
||||
let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap();
|
||||
scale.keys[0].muted = true;
|
||||
scale.value = Value::Vec3([50.0, 200.0, 100.0]);
|
||||
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_without_prerender_leaves_text_out() {
|
||||
let (p, cid) = ae_project();
|
||||
|
||||
+51
-20
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -73,9 +73,22 @@ fn is_spatial(p: &Property) -> bool {
|
||||
}
|
||||
|
||||
/// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions).
|
||||
/// `label` names the property in warnings.
|
||||
/// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and
|
||||
/// the remaining keys carry the animation the renderer shows.
|
||||
pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json {
|
||||
let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) };
|
||||
let muted = p.keys.iter().any(|k| k.muted);
|
||||
if muted {
|
||||
ex.warn(format!("{label}: muted keyframes are not exported"));
|
||||
}
|
||||
let active: Vec<Keyframe>;
|
||||
let keys: &[Keyframe] = if muted {
|
||||
active = p.keys.iter().filter(|k| !k.muted).cloned().collect();
|
||||
&active
|
||||
} else {
|
||||
&p.keys
|
||||
};
|
||||
let mut out =
|
||||
if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) };
|
||||
if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) {
|
||||
if ex.include_expressions {
|
||||
out["x"] = json!(e.text);
|
||||
@@ -86,20 +99,18 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js
|
||||
out
|
||||
}
|
||||
|
||||
fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec<Json> {
|
||||
let keys = &p.keys;
|
||||
let spatial = is_spatial(p);
|
||||
fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec<Json> {
|
||||
let mut out = Vec::with_capacity(keys.len());
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))});
|
||||
if i + 1 == keys.len() {
|
||||
if k.out_interp == Interp::Hold || p.hold_only {
|
||||
if k.out_interp == Interp::Hold || hold_only {
|
||||
o["h"] = json!(1);
|
||||
}
|
||||
out.push(o);
|
||||
continue;
|
||||
}
|
||||
if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() {
|
||||
if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() {
|
||||
o["h"] = json!(1);
|
||||
out.push(o);
|
||||
continue;
|
||||
|
||||
@@ -878,3 +878,21 @@ fn external_images_are_local_or_skipped() {
|
||||
assert_eq!(std::path::Path::new(&paths[0]), base.join("images").join("a.png"));
|
||||
assert_eq!(r.warnings.iter().filter(|w| w.contains("is not imported")).count(), assets.len() - 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muted_keys_are_omitted_from_the_export() {
|
||||
let (mut p, cid) = setup();
|
||||
let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20);
|
||||
let mut b = Keyframe::new(s(1.0), Value::Scalar(100.0));
|
||||
b.muted = true;
|
||||
l.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), b, Keyframe::new(s(2.0), Value::Scalar(300.0))];
|
||||
push(&mut p, cid, l);
|
||||
let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||
assert!(warnings.iter().any(|w| w.contains("muted keyframes")), "{warnings:?}");
|
||||
// Lottie has no mute state: the two active keys carry the 0 → 300 animation.
|
||||
assert_eq!(json["layers"][0]["ks"]["o"]["k"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(json["layers"][0]["ks"]["o"]["k"][1]["s"], json!([300.0]));
|
||||
let opacity_at = |p: &Project, cid: ItemId, t: f64| p.comp(cid).unwrap().layers[0].props.prop("transform/opacity").unwrap().value_at(s(t)).as_f64();
|
||||
assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6);
|
||||
assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn);
|
||||
turn_footage(&mut f, turn);
|
||||
correct_rate(&mut f, header.as_deref().and_then(video_rate));
|
||||
trim_to_edits(&mut f, header.as_deref().and_then(video_shown));
|
||||
return Ok(f);
|
||||
}
|
||||
let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
|
||||
@@ -107,6 +108,7 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let turn = video_turn(&bytes);
|
||||
let rate = video_rate(&bytes);
|
||||
let shown = video_shown(&bytes);
|
||||
// A WAV's cue points and XMP markers become the footage's markers.
|
||||
let markers = crate::wav_markers::read(&bytes);
|
||||
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
|
||||
@@ -114,9 +116,37 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
|
||||
f.markers = markers;
|
||||
turn_footage(&mut f, turn);
|
||||
correct_rate(&mut f, rate);
|
||||
trim_to_edits(&mut f, shown);
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// A movie lasts as long as its video edit list shows (#2592): the reader reports the media
|
||||
/// duration (`mdhd`), which for H.264 with B-frames runs past the last shown frame by the
|
||||
/// encoder delay the edit list trims (75 frames at 25 fps read as 3.04 s). Never lengthens.
|
||||
fn trim_to_edits(f: &mut Footage, shown: Option<Tick>) {
|
||||
if f.kind == FootageKind::Video
|
||||
&& let Some(shown) = shown.filter(|s| *s > Tick::ZERO && *s < f.duration)
|
||||
{
|
||||
f.duration = shown;
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the video track's edit list shows its media (`elst` segments, movie timescale):
|
||||
/// `None` without edits, or with an empty edit (a delayed start keeps the reader's duration).
|
||||
fn video_shown(bytes: &[u8]) -> Option<Tick> {
|
||||
let file = filmcraft_isobmff::open(bytes).ok()?;
|
||||
let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?;
|
||||
edits_shown(&t.edits, file.timescale)
|
||||
}
|
||||
|
||||
fn edits_shown(edits: &[filmcraft_isobmff::Edit], timescale: u32) -> Option<Tick> {
|
||||
if edits.is_empty() || timescale == 0 || edits.iter().any(|e| e.media_time < 0) {
|
||||
return None;
|
||||
}
|
||||
let units = edits.iter().try_fold(0u64, |acc, e| acc.checked_add(e.segment_duration))?;
|
||||
Some(Tick::from_units(i64::try_from(units).ok()?, i64::from(timescale)))
|
||||
}
|
||||
|
||||
/// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's
|
||||
/// own estimate misreads it.
|
||||
fn correct_rate(f: &mut Footage, rate: Option<FrameRate>) {
|
||||
@@ -322,6 +352,24 @@ fn averaged_rate(timescale: u32, durations: impl Iterator<Item = u32>) -> Option
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a
|
||||
/// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it.
|
||||
#[test]
|
||||
fn the_edit_list_trims_a_movie_to_the_frames_it_shows() {
|
||||
let edit = |segment_duration, media_time| filmcraft_isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 };
|
||||
let shown = edits_shown(&[edit(3000, 1024)], 1000);
|
||||
assert_eq!(shown, Some(Tick::from_units(3, 1)));
|
||||
let mut f = Footage { kind: FootageKind::Video, duration: Tick::from_units(76, 25), ..Footage::default() };
|
||||
trim_to_edits(&mut f, shown);
|
||||
assert_eq!(f.duration, Tick::from_units(75, 25));
|
||||
// Never longer than the reader's duration, and only for video.
|
||||
trim_to_edits(&mut f, Some(Tick::from_units(4, 1)));
|
||||
assert_eq!(f.duration, Tick::from_units(75, 25));
|
||||
assert_eq!(edits_shown(&[edit(1000, -1), edit(3000, 0)], 1000), None);
|
||||
assert_eq!(edits_shown(&[], 1000), None);
|
||||
assert_eq!(edits_shown(&[edit(3000, 0)], 0), None);
|
||||
}
|
||||
|
||||
/// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale.
|
||||
fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec<u32> {
|
||||
let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(©_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, ©_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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -385,7 +385,8 @@ pub fn scan_system() -> usize {
|
||||
}
|
||||
|
||||
/// Scan the system font folders again, adding fonts installed since the last scan (faces already
|
||||
/// known are kept, so font ids stay valid). Returns the number of faces added.
|
||||
/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every
|
||||
/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread.
|
||||
pub fn rescan_system() -> usize {
|
||||
let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
|
||||
@@ -393,6 +394,60 @@ pub fn rescan_system() -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static RESCAN: std::sync::Mutex<Option<std::thread::JoinHandle<usize>>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already
|
||||
/// running, the thread could not be spawned, or this build has no system font folders (wasm).
|
||||
pub fn start_rescan() -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if slot.as_ref().is_some_and(|h| !h.is_finished()) {
|
||||
return false;
|
||||
}
|
||||
match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) {
|
||||
Ok(handle) => {
|
||||
*slot = Some(handle);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a background rescan is still walking the font folders.
|
||||
pub fn rescan_busy() -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().is_some_and(|h| !h.is_finished())
|
||||
}
|
||||
}
|
||||
|
||||
/// Faces added by the latest background rescan, once that thread has finished. `None` while it
|
||||
/// is still running or when nothing was started.
|
||||
pub fn poll_rescan() -> Option<usize> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
None
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished);
|
||||
if finished { slot.take().and_then(|h| h.join().ok()) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the system font scan on a background thread, so the font menus are complete by the
|
||||
/// time someone opens them without the first open waiting for the scan. Native only; on wasm
|
||||
/// (no threads, no font folders) this does nothing.
|
||||
|
||||
@@ -203,6 +203,8 @@ fn system_scan_finds_fonts() {
|
||||
let n = all_faces().iter().filter(|f| f.info.origin == "system").count();
|
||||
eprintln!("system fonts: {n} faces in {:?}", t.elapsed());
|
||||
assert_eq!(scan_system(), 0, "scans once");
|
||||
// A minimum face count on Linux belongs with the native-fonts gate in #1591. A container
|
||||
// that has fontconfig and only a handful of faces must still pass this portable test.
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(n > 20, "{n}");
|
||||
let r = resolve("Helvetica", "Bold");
|
||||
|
||||
@@ -144,6 +144,9 @@ impl EffectcraftApp {
|
||||
// commands handled here use the right catalog already.
|
||||
let language = crate::i18n::language(self);
|
||||
crate::i18n::set_current(language);
|
||||
// #1089: a font-menu rescan runs off the UI thread. Poll it on the logic tick so the
|
||||
// status and the menu update even when the window is not painting.
|
||||
crate::panels::text_panels::poll_font_rescan(self, ctx);
|
||||
if self.styled_language != language {
|
||||
self.want_paint = true;
|
||||
}
|
||||
|
||||
@@ -266,6 +266,8 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin
|
||||
"es" => Some(("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer")),
|
||||
"pt-br" => Some(("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer")),
|
||||
"cs" => Some(("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat")),
|
||||
"fr" => Some(("Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir")),
|
||||
"de" => Some(("Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich")),
|
||||
// A colon before the history's (untranslated) action name: "დაბრუნება: Comp1".
|
||||
"ka" => Some(("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია")),
|
||||
// A colon before the history's (untranslated) action name: "Отменить: Comp1".
|
||||
@@ -836,6 +838,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "行落下"),
|
||||
("anim.addKeyframe", "Add Keyframe", "添加关键帧"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "切换定格关键帧"),
|
||||
("keys.setMuted", "Mute Keyframes", "静音关键帧"),
|
||||
("keys.setMuted", "Unmute Keyframes", "取消静音关键帧"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "关键帧插值..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "关键帧速度..."),
|
||||
("", "Keyframe Assistant", "关键帧辅助"),
|
||||
@@ -1472,6 +1476,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "行落下"),
|
||||
("anim.addKeyframe", "Add Keyframe", "新增關鍵影格"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "切換定格關鍵影格"),
|
||||
("keys.setMuted", "Mute Keyframes", "靜音關鍵影格"),
|
||||
("keys.setMuted", "Unmute Keyframes", "取消靜音關鍵影格"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "關鍵影格插補..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "關鍵影格速度..."),
|
||||
("", "Keyframe Assistant", "關鍵影格輔助"),
|
||||
@@ -2105,6 +2111,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"),
|
||||
("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"),
|
||||
("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"),
|
||||
("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "キーフレーム速度..."),
|
||||
("", "Keyframe Assistant", "キーフレーム補助"),
|
||||
@@ -2741,6 +2749,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Поява рядків згори"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Додати ключовий кадр"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Перемкнути утримання ключового кадру"),
|
||||
("keys.setMuted", "Mute Keyframes", "Заглушити ключові кадри"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Зняти заглушення ключових кадрів"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Інтерполяція ключових кадрів..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Швидкість ключових кадрів..."),
|
||||
("", "Keyframe Assistant", "Помічник ключових кадрів"),
|
||||
@@ -3378,6 +3388,8 @@ const SPANISH: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Entrada de líneas cayendo"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"),
|
||||
("keys.setMuted", "Mute Keyframes", "Silenciar fotogramas clave"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Reactivar fotogramas clave"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."),
|
||||
("", "Keyframe Assistant", "Asistente de fotogramas clave"),
|
||||
@@ -4013,6 +4025,8 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Entrar com queda de linhas"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"),
|
||||
("keys.setMuted", "Mute Keyframes", "Silenciar quadros-chave"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Reativar quadros-chave"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."),
|
||||
("", "Keyframe Assistant", "Assistente de quadro-chave"),
|
||||
@@ -4655,6 +4669,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"),
|
||||
("keys.setMuted", "Mute Keyframes", "Отключить ключевые кадры"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Включить ключевые кадры"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."),
|
||||
("", "Keyframe Assistant", "Помощник по ключевым кадрам"),
|
||||
@@ -5290,6 +5306,8 @@ const FRENCH: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"),
|
||||
("keys.setMuted", "Mute Keyframes", "Désactiver les images clés"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Réactiver les images clés"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."),
|
||||
("", "Keyframe Assistant", "Assistant d'images clés"),
|
||||
@@ -5925,6 +5943,8 @@ const GERMAN: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"),
|
||||
("keys.setMuted", "Mute Keyframes", "Keyframes stummschalten"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Keyframes wieder aktivieren"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."),
|
||||
("", "Keyframe Assistant", "Keyframe-Assistent"),
|
||||
@@ -6561,6 +6581,8 @@ const CZECH: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"),
|
||||
("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"),
|
||||
("keys.setMuted", "Mute Keyframes", "Ztlumit klíčové snímky"),
|
||||
("keys.setMuted", "Unmute Keyframes", "Obnovit klíčové snímky"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."),
|
||||
("", "Keyframe Assistant", "Pomocník klíčových snímků"),
|
||||
@@ -7192,6 +7214,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"),
|
||||
("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"),
|
||||
("keys.setMuted", "Mute Keyframes", "საკვანძო კადრების დადუმება"),
|
||||
("keys.setMuted", "Unmute Keyframes", "საკვანძო კადრების დადუმების მოხსნა"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."),
|
||||
("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"),
|
||||
@@ -7550,6 +7574,43 @@ mod tests {
|
||||
app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap();
|
||||
assert_eq!(label(&app, "", "Composition"), "Composition");
|
||||
}
|
||||
/// Match System in the browser also picks the Russian, French and German catalogs, in the
|
||||
/// order of the browser's preferences (#1700).
|
||||
#[test]
|
||||
fn browser_preferences_choose_russian_french_and_german() {
|
||||
let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
for (locales, expected) in
|
||||
[(vec!["de-DE", "es-ES"], "de"), (vec!["xx-XX", "fr-CA"], "fr"), (vec!["ru-RU", "cs"], "ru"), (vec!["en-US", "de-DE"], "en"), (vec!["xx-XX"], "en")]
|
||||
{
|
||||
assert_eq!(super::first_supported(None, &tags(&locales), super::web_draws), expected, "{locales:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Edit menu's Undo and Redo name the history's action in the interface language, also in
|
||||
/// the languages added with #1700.
|
||||
#[test]
|
||||
fn undo_and_redo_are_localized_for_russian_french_and_german() {
|
||||
for (language, undo, no_undo, redo, no_redo) in [
|
||||
("ru", "Отменить:", "Нечего отменять", "Повторить:", "Нечего повторять"),
|
||||
("fr", "Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir"),
|
||||
("de", "Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich"),
|
||||
] {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.execute("prefs.set", json!({"key": "general.language", "value": language})).unwrap();
|
||||
let entry_of = |app: &EffectcraftApp, command: &str| {
|
||||
let (_, e) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command).unwrap();
|
||||
entry(app, e, e.label.clone())
|
||||
};
|
||||
assert_eq!(entry_of(&app, "edit.undo"), no_undo, "{language}");
|
||||
assert_eq!(entry_of(&app, "edit.redo"), no_redo, "{language}");
|
||||
app.session.execute("comp.new", json!({"name": "A", "duration": 1})).unwrap();
|
||||
let action = app.session.history.undo.last().map(|u| u.0.clone()).unwrap();
|
||||
assert_eq!(entry_of(&app, "edit.undo"), format!("{undo} {action}"), "{language}");
|
||||
app.session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(entry_of(&app, "edit.redo"), format!("{redo} {action}"), "{language}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_system_uses_the_system_language_where_there_is_a_catalog() {
|
||||
for (locale, language) in [
|
||||
@@ -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...");
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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])));
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,21 @@ struct KeyPt {
|
||||
t: f64,
|
||||
v: f64,
|
||||
sel: bool,
|
||||
muted: bool,
|
||||
handles: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// A graph key's fill and edge colours: muted keys stay visible and selectable, dimmed.
|
||||
fn key_colors(t: &Tokens, edge: Color32, k: &KeyPt) -> (Color32, Color32) {
|
||||
let fill = match (k.muted, k.sel) {
|
||||
(true, true) => t.keyframe_selected.gamma_multiply(0.5),
|
||||
(true, false) => Color32::from_gray(0x70),
|
||||
(false, true) => t.keyframe_selected,
|
||||
(false, false) => Color32::from_gray(0xe0),
|
||||
};
|
||||
(fill, if k.muted { edge.gamma_multiply(0.4) } else { edge })
|
||||
}
|
||||
|
||||
struct Curve<'a> {
|
||||
layer: &'a Layer,
|
||||
prop: &'a Property,
|
||||
@@ -153,7 +165,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl
|
||||
let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 };
|
||||
let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) };
|
||||
let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier };
|
||||
if ks && show && (bezier || speed) {
|
||||
if ks && !k.muted && show && (bezier || speed) {
|
||||
handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur });
|
||||
}
|
||||
}
|
||||
@@ -162,7 +174,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl
|
||||
} else {
|
||||
k.value.components().get(d).copied().unwrap_or(0.0)
|
||||
};
|
||||
keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles });
|
||||
keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, muted: k.muted, handles });
|
||||
}
|
||||
out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial });
|
||||
}
|
||||
@@ -522,9 +534,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
}
|
||||
}
|
||||
// The key itself.
|
||||
let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) };
|
||||
let (col, edge) = key_colors(&t, c.color, k);
|
||||
pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col);
|
||||
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside);
|
||||
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, edge), StrokeKind::Outside);
|
||||
let kr = Rect::from_center_size(kp, vec2(11.0, 11.0));
|
||||
let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
|
||||
|
||||
@@ -451,6 +451,22 @@ fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter
|
||||
}
|
||||
}
|
||||
|
||||
/// Elapsed and estimated remaining seconds at UI time `now`. The job reports them as frames
|
||||
/// finish; between reports they count on from the last one, as a clock does, so a slow frame
|
||||
/// doesn't read as a stalled render (#2593).
|
||||
fn live_times(ctx: &egui::Context, pr: &effectcraft_engine::render_queue::JobState, now: f64) -> (f64, Option<f64>) {
|
||||
let report = (pr.current, pr.done, pr.item_elapsed.to_bits());
|
||||
let since = ctx.data_mut(|d| {
|
||||
let seen = d.get_temp_mut_or_insert_with(egui::Id::unique("rq-progress-seen"), || (report, now));
|
||||
if seen.0 != report {
|
||||
*seen = (report, now);
|
||||
}
|
||||
seen.1
|
||||
});
|
||||
let dt = if pr.finished { 0.0 } else { (now - since).max(0.0) };
|
||||
(pr.item_elapsed + dt, pr.remaining.map(|r| (r - dt).max(0.0)))
|
||||
}
|
||||
|
||||
/// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify
|
||||
/// and the progress bar.
|
||||
fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec<Action>) {
|
||||
@@ -461,11 +477,15 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P
|
||||
let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id));
|
||||
let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone());
|
||||
if let Some(pr) = &progress {
|
||||
let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into());
|
||||
let (elapsed, remaining) = live_times(ui.ctx(), pr, ui.input(|i| i.time));
|
||||
if rendering {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let rem = remaining.map(hms).unwrap_or_else(|| "–".into());
|
||||
p.text(
|
||||
pos2(top.max.x - 12.0, top.min.y + 16.0),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)),
|
||||
format!("Elapsed: {} Est. Remain: {rem}", hms(elapsed)),
|
||||
Tokens::ui(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
@@ -903,4 +923,26 @@ mod tests {
|
||||
assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15");
|
||||
assert_eq!(super::hms(3725.4), "1:02:05");
|
||||
}
|
||||
|
||||
/// #2593: between progress reports the times count on from the last report, and a new
|
||||
/// report starts the count again.
|
||||
#[test]
|
||||
fn render_times_tick_between_reports() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut pr = effectcraft_engine::render_queue::JobState {
|
||||
current: Some(1),
|
||||
done: 2,
|
||||
total: 10,
|
||||
item_elapsed: 20.0,
|
||||
remaining: Some(80.0),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(super::live_times(&ctx, &pr, 100.0), (20.0, Some(80.0)));
|
||||
assert_eq!(super::live_times(&ctx, &pr, 103.0), (23.0, Some(77.0)));
|
||||
assert_eq!(super::live_times(&ctx, &pr, 300.0).1, Some(0.0), "never below zero");
|
||||
(pr.done, pr.item_elapsed, pr.remaining) = (3, 31.0, Some(72.0));
|
||||
assert_eq!(super::live_times(&ctx, &pr, 301.0), (31.0, Some(72.0)));
|
||||
pr.finished = true;
|
||||
assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -360,31 +360,36 @@ pub(crate) fn motion_path(
|
||||
}
|
||||
let v = k.value.as_vec3();
|
||||
let s = to_scr(v);
|
||||
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
|
||||
// ends), to drag it into a curve; Linear keys (no tangents) show none.
|
||||
let (tin, tout) = if k.spatial_auto {
|
||||
effectcraft_engine::keyframe::spatial_handles(&pos.keys, i)
|
||||
} else {
|
||||
effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i)
|
||||
};
|
||||
for (out, tg) in [(false, tin), (true, tout)] {
|
||||
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
|
||||
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
|
||||
continue;
|
||||
let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time });
|
||||
// A muted position key is off the motion path: no tangents, and a dimmed point.
|
||||
let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
|
||||
if !k.muted {
|
||||
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
|
||||
// ends), to drag it into a curve; Linear keys (no tangents) show none.
|
||||
let (tin, tout) = if k.spatial_auto {
|
||||
effectcraft_engine::keyframe::spatial_handles(&pos.keys, i)
|
||||
} else {
|
||||
effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i)
|
||||
};
|
||||
for (out, tg) in [(false, tin), (true, tout)] {
|
||||
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
|
||||
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
|
||||
continue;
|
||||
}
|
||||
let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, kc));
|
||||
painter.circle_filled(h, 3.0, kc);
|
||||
let side = if out { "out" } else { "in" };
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
|
||||
}
|
||||
let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, col));
|
||||
painter.circle_filled(h, 3.0, col);
|
||||
let side = if out { "out" } else { "in" };
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
|
||||
}
|
||||
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
|
||||
if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) {
|
||||
painter.rect_filled(r, 0.0, col);
|
||||
if selected {
|
||||
painter.rect_filled(r, 0.0, kc);
|
||||
} else {
|
||||
painter.rect_filled(r, 0.0, Color32::from_black_alpha(140));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });
|
||||
|
||||
@@ -632,6 +632,27 @@ fn import_dialog_offers_the_image_sequence_option() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #2573: the empty Composition viewer's New Composition From Footage imports the picked file
|
||||
/// (through the Import Image Sequence question a numbered still asks) and makes a composition
|
||||
/// of it, as in After Effects.
|
||||
#[test]
|
||||
fn new_composition_from_footage_makes_a_comp_of_the_picked_file() {
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default()));
|
||||
h.run_steps(3);
|
||||
let dir = frames("fromfootage", 2);
|
||||
let pick = dir.join("frame_0002.png").to_string_lossy().to_string();
|
||||
h.state_mut().session.importer = Some(Arc::new(Stills));
|
||||
h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()]));
|
||||
click_id(&mut h, "viewer.empty.import");
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form));
|
||||
click_id(&mut h, "form.ok");
|
||||
step_until(&mut h, |h| h.state().session.active_comp().is_some());
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
assert_eq!((comp.width, comp.height), (64, 32));
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["frame_0002.png"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret
|
||||
/// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in
|
||||
/// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one
|
||||
|
||||
@@ -256,3 +256,24 @@ fn font_menu_grows_back_after_an_empty_search() {
|
||||
click(&mut h, "character.font.option.JetBrains Mono");
|
||||
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono", "a click on a row picks the font");
|
||||
}
|
||||
|
||||
/// #1089: the font menu can re-run the system scan without restarting the app, and the press
|
||||
/// does not also flip the "Variable fonts only" filter.
|
||||
#[test]
|
||||
fn font_menu_rescan_button_adds_fonts_installed_later() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
|
||||
s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::Character);
|
||||
h.run_steps(3);
|
||||
click(&mut h, "character.font");
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan");
|
||||
click(&mut h, "character.font.rescan");
|
||||
assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status);
|
||||
// The popup stays open, the variable filter did not flip, and the list still has the bundled families.
|
||||
assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open");
|
||||
let rows = h.state().auto.query("character.font.option.");
|
||||
assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user