Merge remote-tracking branch 'origin/main' into pr-1608

# Conflicts:
#	contributors/people.toml
This commit is contained in:
bflatastic committed 2026-10-16 18:14:23 +07:00
commit 2dcceeea90
92 files changed
+2399 -234

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
Generated
+1
View File
@@ -1455,6 +1455,7 @@ dependencies = [
"log",
"rayon",
"serde_json",
"symphonia",
"thiserror 2.0.21",
]
+3
View File
@@ -92,6 +92,9 @@ filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned
# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that.
symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
# gpu (the same wgpu eframe / egui-wgpu use)
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
+23 -1
View File
@@ -263,8 +263,18 @@ fn value_arg(s: &str) -> Value {
serde_json::from_str(s).unwrap_or_else(|_| json!(s))
}
/// The command line as text. `std::env::args` panics on an argument that is not Unicode (a
/// Latin-1 file name on Linux). Every argument is used as a string, and a lossy copy would read or
/// write another file than the one named, so such an argument is a usage error naming it.
fn utf8_args(raw: impl IntoIterator<Item = std::ffi::OsString>) -> Result<Vec<String>, String> {
raw.into_iter().map(|a| a.into_string().map_err(|a| format!("argument `{}` is not valid Unicode", a.to_string_lossy()))).collect()
}
fn main() {
let raw: Vec<String> = std::env::args().skip(1).collect();
let raw = match utf8_args(std::env::args_os().skip(1)) {
Ok(raw) => raw,
Err(e) => fail_usage(&e),
};
if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) {
say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
return;
@@ -1270,6 +1280,18 @@ mod tests {
assert!(a.flag("--work-area") && a.flag("--json"));
}
/// `std::env::args` panicked on an argument that is not Unicode; it is a usage error naming
/// it, and Unicode arguments pass through unchanged.
#[cfg(unix)]
#[test]
fn non_unicode_arguments_are_refused() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let args = [OsString::from("render-frame"), OsString::from("--out"), OsString::from_vec(b"caf\xe9.png".to_vec())];
assert_eq!(utf8_args(args).unwrap_err(), "argument `caf\u{fffd}.png` is not valid Unicode");
assert_eq!(utf8_args([OsString::from("info"), OsString::from("caf\u{e9}.png")]).unwrap(), ["info", "caf\u{e9}.png"]);
}
/// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused.
#[test]
fn every_option_read_is_declared() {
+24 -2
View File
@@ -25,12 +25,19 @@ fn ok_json(args: &[&str]) -> Value {
v
}
/// `std::fs::canonicalize` without Windows' `\\?\` verbatim prefix, which a child's `current_dir()` never reports.
fn canonical(path: &std::path::Path) -> std::io::Result<PathBuf> {
let path = std::fs::canonicalize(path)?;
let plain = path.to_str().and_then(|s| s.strip_prefix(r"\\?\")).filter(|s| s.as_bytes().get(1) == Some(&b':'));
Ok(plain.map(PathBuf::from).unwrap_or(path))
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
// macOS's `/var` is a symlink to `/private/var`: a child process resolves its working
// directory through `current_dir()`, which is canonical, so compare canonical paths.
std::fs::canonicalize(&d).unwrap_or(d).join(name)
canonical(&d).unwrap_or(d).join(name)
}
#[test]
@@ -160,6 +167,21 @@ fn unknown_options_are_usage_errors() {
}
}
/// An argument that is not Unicode (a Latin-1 file name on Linux) is a usage error (exit 2)
/// naming it, before anything runs: `std::env::args` panicked on one (exit 101).
#[cfg(unix)]
#[test]
fn non_unicode_arguments_are_usage_errors() {
use std::os::unix::ffi::OsStrExt;
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
let out = bin().args(["render-frame", "--out"]).arg(name).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{err}");
assert!(err.contains("argument `caf\u{fffd}.png` is not valid Unicode"), "{err}");
assert!(!err.contains("panicked"), "{err}");
assert!(out.stdout.is_empty(), "nothing ran");
}
/// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command
/// still does its work and exits 0, without a panic (#167).
#[test]
@@ -260,7 +282,7 @@ fn render_out_is_relative_to_the_working_directory() {
std::fs::create_dir_all(&proj).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
// current_dir resolves aliases such as macOS /var -> /private/var.
let root = root.canonicalize().unwrap();
let root = canonical(&root).unwrap();
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
let project = proj.join("x.ecproj");
let p = project.to_str().unwrap();
+5 -1
View File
@@ -75,10 +75,14 @@ sync_github_name = true
real_name = "Robert Lee"
sync_github_name = true
[people.moecr7]
[people.onlymoein]
real_name = "Moein Saharkhiz"
display_name = "onlymoein"
[people.arnav-hiwarkar]
display_name = "arnav-hiwarkar"
sync_github_name = false
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
+30 -1
View File
@@ -589,7 +589,7 @@ pub fn specs() -> Vec<EffectSpec> {
p("meshColor", "Mesh Color", col(0.5, 0.5, 0.5), ParamUi::Color),
// The strokes (see `LiquifyStroke`); written by the `liquify.stroke` command.
p("distortionMesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden),
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden),
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Point),
p("distortionPercentage", "Distortion Percentage", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("distortionDisplayQuality", "Distortion Display Quality", Value::Enum(1), popup(&["Draft", "Smooth"])),
],
@@ -672,6 +672,35 @@ mod tests {
assert_eq!(zero.img.data, img.data);
let again = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(out.img.data, again.img.data);
// #1194: Distortion Mesh Offset is an exposed, editable control (AE's Distortion ▸
// Offset) and it moves the mesh: the pattern rendered at x renders at x + offset.
let spec = crate::find("ec.distort.liquify").unwrap();
assert!(
spec.params.iter().any(|ps| ps.id == "distortionMeshOffset" && ps.ui == effectcraft_project::ParamUi::Point),
"offset is shown in Effect Controls"
);
let shifted = run_fx(
"ec.distort.liquify",
&[("distortionMesh", Value::Str(s.to_line())), ("distortionMeshOffset", Value::Vec2([20.0, 0.0]))],
img.clone(),
0.0,
EffectEnv::default(),
);
assert_ne!(shifted.img.data, out.img.data, "the offset changes the render");
// The displaced region's centroid moves right by the offset (the mesh is sampled at
// layer minus offset), and the row far left of the shifted mesh is back to untouched.
let centroid = |o: &Image| -> f32 {
let (mut wsum, mut msum) = (0.0f32, 0.0f32);
for (i, (p, q)) in o.data.iter().zip(img.data.iter()).enumerate() {
let d: f32 = (0..4).map(|k| (p[k] - q[k]).abs()).sum();
wsum += d;
msum += d * (i % 64) as f32;
}
if wsum > 1e-6 { msum / wsum } else { 0.0 }
};
let (co, cs) = (centroid(&out.img), centroid(&shifted.img));
assert!((cs - co - 20.0).abs() < 5.0, "centroids {co} -> {cs}");
assert_eq!(shifted.img.get(5, 32), img.get(5, 32), "left of the shifted mesh: untouched");
// Reconstruction undoes the warp; freeze protects.
let mut rec = s.clone();
rec.tool = 9;
+23 -1
View File
@@ -138,7 +138,10 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let n = (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * jitter * (8.0 / 255.0);
c = c.map(|v| v + n);
}
*px = gen_blend(*px, [c[0] * op, c[1] * op, c[2] * op, op], mode);
// None keeps the layer's alpha (#1381), as the Gradient Ramp does: the gradient fills
// a shape or text, not its bounding box.
let a = if gen_mode(mode).is_none() { op * px[3] } else { op };
*px = gen_blend(*px, [c[0] * a, c[1] * a, c[2] * a, a], mode);
}
});
b
@@ -420,6 +423,25 @@ mod generator_tests {
}
}
#[test]
fn four_color_none_stays_inside_the_layer_alpha() {
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
let mut img = Image::filled(40, 24, [1.0, 0.0, 0.0, 1.0]);
for (i, p) in img.data.iter_mut().enumerate() {
let a = [0.0, 0.5, 1.0][i % 40 / 14];
*p = p.map(|c| c * a);
}
for op in [100.0, 50.0] {
let o = run_fx("ec.generate.fourcolor", &[("opacity", num(op))], img.clone(), 0.0, EffectEnv::default());
let k = op as f32 / 100.0;
for (src, out) in img.data.iter().zip(&o.img.data) {
assert!((src[3] * k - out[3]).abs() < 1e-6, "opacity {op}: alpha {} became {}", src[3], out[3]);
assert!(out[..3].iter().all(|&c| c <= out[3] + 1e-6), "opacity {op}: premultiplied colour above alpha");
}
assert!(o.img.data.iter().any(|p| p[1] > 0.1), "opacity {op}: the gradient replaces the layer's colour");
}
}
#[test]
fn checkerboard_blending_none_shows_pattern_only_and_size_from_width() {
let set = [("width", num(8.0)), ("height", num(2.0))];
+15
View File
@@ -239,6 +239,8 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) {
// A path typed as text, or set only by commands, that got a Choose… button (Apply Color
// LUT's LUT, the OCIO files, #514).
(ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(),
// An internal point that became a control (Liquify's Distortion Mesh Offset, #1194).
(ParamUi::Hidden, ParamUi::Point) => pr.ui = ParamUi::Point,
// Same kind of value: take the spec's hints (units, ranges).
(a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(),
_ => {}
@@ -514,6 +516,19 @@ mod tests {
}
}
/// #1194: Liquify's Distortion Mesh Offset was hidden; old projects show it, keeping its value.
#[test]
fn a_hidden_point_that_became_a_control_is_shown() {
let mut next = 1;
let (spec, mut g) = instance("ec.distort.liquify", &mut next);
let pr = g.get_mut("distortionMeshOffset").unwrap();
pr.ui = ParamUi::Hidden;
pr.value = Value::Vec2([20.0, -5.0]);
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]));
let pr = g.get("distortionMeshOffset").unwrap();
assert_eq!((&pr.ui, &pr.value), (&ParamUi::Point, &Value::Vec2([20.0, -5.0])));
}
#[test]
fn upgrade_moves_params_into_twirl_down_groups() {
let spec = find(crate::warp_stab::ID).unwrap();
+7 -6
View File
@@ -228,9 +228,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
}
// Keyframes selected → copy keys (pasted at the CTI).
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
s.state.link_clipboard = None;
s.state.effect_clipboard.clear();
s.state.contents_clipboard.clear();
clear_clipboards(s);
return s.execute("keys.copy", json!({}));
}
// Effects selected (Effect Controls / timeline) → copy the effects.
@@ -256,12 +254,14 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After
/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and
/// an unanimated one its value, for Paste onto the same properties of other layers (#493).
/// Effects: an animated property copies its keyframes and an unanimated one its value, for Paste
/// onto the same properties of other layers (#493). With keyframes selected only those are copied,
/// even across several highlighted properties (#1552); a selected Transform group copies them all.
/// `None` when no property (or Transform group) is selected.
fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
let Some(comp) = s.active_comp() else { return Ok(None) };
let mut leaves = vec![];
let mut copy_all_keys = s.state.selected_keys.is_empty();
for (lid, uid) in &s.state.selected_props {
let Some(l) = comp.layer(*lid) else { continue };
if l.props.find(*uid).is_some() {
@@ -269,6 +269,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
} else if let Some(g) = l.props.find_group(*uid)
&& l.props.match_path_of(*uid).as_deref() == Some("transform")
{
copy_all_keys = true;
g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid)));
}
}
@@ -286,7 +287,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
{
values.push((path, pr.value.clone()));
}
} else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) {
} else if copy_all_keys && !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) {
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
}
}
+3 -3
View File
@@ -391,7 +391,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
Ok(b) if effectcraft_psd::is_psd(&b) => b,
Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => {
// PDF / Illustrator / EPS: one layer per file layer.
match import_vector_comp(s, &path, &b, page) {
match import_vector_comp(s, &path, &b, page, retain) {
Ok((comp, made)) => {
comps.push(comp);
items.extend(made);
@@ -540,7 +540,7 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retai
/// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step).
/// Returns (comp, items).
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec<u64>)> {
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32, retain: bool) -> Result<(u64, Vec<u64>)> {
let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into());
if page > 0 {
name = format!("{name} (Page {})", page + 1);
@@ -549,7 +549,7 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R
let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs));
let (comp, folder, items) = s.edit("Import", None, |proj, st| {
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?;
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, retain, (rate, duration)).map_err(EngineError::Other)?;
st.project_selection = vec![r.0];
Ok(r)
})?;
+1 -2
View File
@@ -356,13 +356,12 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"effects": found}))
}
"fonts" => {
let families = crate::text_families();
let mut found = vec![];
for (cid, c) in s.project.comps() {
for l in &c.layers {
if let Some(pr) = l.props.prop("text/sourceText")
&& let Some(doc) = pr.value.as_text()
&& !families.iter().any(|f| f.eq_ignore_ascii_case(&doc.font))
&& effectcraft_text::resolve(&doc.font, &doc.style).missing
{
found.push(json!({"comp": cid.0, "layer": l.id.0, "font": doc.font}));
}
+16 -11
View File
@@ -126,17 +126,18 @@ pub(crate) fn find_moved(services: &dyn Services, project_dir: &str, f: &Footage
})
}
/// The folder a project file is in.
/// The folder a project file is in, with the path's own separator and its leading `/` or UNC `\\`.
pub(crate) fn project_dir(project_path: &str) -> Option<String> {
let p = parts(project_path);
if p.len() < 2 {
return None;
}
let mut dir = join(if project_path.starts_with('/') { "/" } else { "" }, p.get(..p.len() - 1)?);
if !project_path.starts_with('/') {
dir = dir.trim_start_matches(['/', '\\']).to_string();
}
Some(dir)
let dir = p.get(..p.len().checked_sub(1)?).filter(|d| !d.is_empty())?;
let root = if project_path.starts_with("\\\\") {
"\\\\"
} else if project_path.starts_with('/') {
"/"
} else {
""
};
Some(format!("{root}{}", dir.join(&sep_of(project_path).to_string())))
}
/// Relink `found` in one undo step and say how many.
@@ -262,6 +263,10 @@ mod tests {
);
assert_eq!(project_dir("/x/y/p.ecproj").as_deref(), Some("/x/y"));
assert_eq!(project_dir("p.ecproj"), None);
// Windows paths keep their separator, and a UNC share its `\\`.
assert_eq!(project_dir("C:\\Work\\Job\\p.ecproj").as_deref(), Some("C:\\Work\\Job"));
assert_eq!(project_dir("\\\\server\\share\\Job\\p.ecproj").as_deref(), Some("\\\\server\\share\\Job"));
assert_eq!(moved_candidates("\\\\server\\share\\Job", "D:\\Old\\Job\\shots\\a.png")[2], "\\\\server\\share\\Job\\shots\\a.png");
// Hostile input doesn't panic.
for (a, b) in [("", ""), ("/", "/"), ("x", ""), ("\\\\", "//"), ("/a", "/a/b/c")] {
let _ = moved_root(a, b);
@@ -302,11 +307,11 @@ mod tests {
let b = add(&mut s, "/gone/proj/shots/b.mov");
let m = add(&mut s, "/gone/proj/audio/m.wav");
let other = add(&mut s, "/elsewhere/c.mov");
let target = new.join("shots/a.mov").to_string_lossy().to_string();
let target = new.join("shots").join("a.mov").to_string_lossy().to_string();
let r = s.execute("footage.relink", json!({"item": a.0, "path": target})).unwrap();
assert_eq!(r["relinked"], json!([a.0, b.0, m.0]), "{r}");
assert_eq!(r["stillMissing"], 1);
assert_eq!(path_of(&s, m), new.join("audio/m.wav").to_string_lossy());
assert_eq!(path_of(&s, m), new.join("audio").join("m.wav").to_string_lossy());
assert_eq!(path_of(&s, other), "/elsewhere/c.mov");
// One undo step brings every path back.
s.execute("edit.undo", json!({})).unwrap();
+6 -3
View File
@@ -6,6 +6,7 @@ use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Layer, LayerSource, Node, PropGroup};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use std::sync::OnceLock;
use super::{CommandSpec, b_p, bad, has_comp, layer_mut, layer_p, str_p};
use crate::{Result, Session, cmd, query};
@@ -13,8 +14,10 @@ use crate::{Result, Session, cmd, query};
/// The original text animator presets (`presets/text_animators.json`).
pub const PRESETS_JSON: &str = include_str!("../../presets/text_animators.json");
fn presets() -> Vec<Value> {
serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
fn presets() -> &'static [Value] {
static PRESETS: OnceLock<Vec<Value>> = OnceLock::new();
PRESETS
.get_or_init(|| serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default())
}
/// (id, name) of every preset.
@@ -303,7 +306,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, C)?;
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string();
let preset = presets()
.into_iter()
.iter()
.find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want)))
.ok_or_else(|| bad(C, format!("no preset {want}")))?;
let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string();
+2 -2
View File
@@ -254,7 +254,7 @@ mod tests {
std::fs::write(d.join("Job/shots/a.png"), b"x").unwrap();
// Saved on another machine: the footage sat next to the project in /Users/x/Job.
let mut s = session_with(&["/Users/x/Job/shots/a.png", "/Users/x/elsewhere/b.png"]);
let path = d.join("Job/p.ecproj");
let path = d.join("Job").join("p.ecproj");
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let mut o = Session::default();
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
@@ -269,7 +269,7 @@ mod tests {
_ => None,
})
.collect();
assert!(paths.contains(&d.join("Job/shots/a.png").to_string_lossy().to_string()), "{paths:?}");
assert!(paths.contains(&d.join("Job").join("shots").join("a.png").to_string_lossy().to_string()), "{paths:?}");
assert!(!o.is_dirty(), "finding the files is not an edit");
let _ = std::fs::remove_dir_all(d);
}
+7
View File
@@ -58,6 +58,12 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u
Some((u32::try_from(found.index).ok()?, size))
}
fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> {
let bytes = s.services.read_file(&f.path).ok()?;
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?;
(!doc.root.children.is_empty()).then(|| doc.pixel_size())
}
/// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`).
fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
let mut nf = f.clone();
@@ -89,6 +95,7 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
match &f.layer {
// A smart object's size is its embedded file's, recorded at import.
Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height),
Some(_) if crate::vector::is_vector(f) => (w, h) = vector_size(s, f).unwrap_or((f.width, f.height)),
Some(l) => match psd_layer(s, &f.path, l) {
Some((index, size)) => {
if let Some(nl) = nf.layer.as_mut() {
+68 -3
View File
@@ -173,7 +173,7 @@ impl VirtualTree {
})
})
.collect();
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
sort_entries(&mut out);
Ok(out)
}
}
@@ -193,6 +193,10 @@ pub struct Entry {
pub kind: Option<&'static str>,
}
fn sort_entries(entries: &mut [Entry]) {
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
}
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
@@ -251,7 +255,7 @@ pub fn list(dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind })
})
.collect();
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
sort_entries(&mut out);
Ok(out)
}
@@ -312,7 +316,9 @@ pub fn footage_metadata(f: &Footage) -> Value {
"pixelAspect": f.pixel_aspect,
"frameRate": f.frame_rate.as_f64(),
"duration": f.duration.seconds(),
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.duration)) } else { None },
// Rounded like the media pool's frame count: Matroska stores the duration in whole
// milliseconds, a hair short of the last frame at 30 fps (#1429).
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.frame_rate.snap_nearest(f.duration))) } else { None },
"hasVideo": f.has_video,
"hasAudio": f.has_audio,
"alpha": format!("{:?}", f.alpha),
@@ -407,6 +413,10 @@ mod tests {
assert_eq!(media.len(), 3);
assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str()));
assert!(list(&dir.join("nope").to_string_lossy(), false).is_err());
std::fs::write(dir.join("Ω.csv"), b"x").unwrap();
std::fs::write(dir.join("作品.csv"), b"x").unwrap();
let all = list(&d, false).unwrap();
assert_eq!(all.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Shots", "A.mov", "b.png", "notes.txt", "Ω.csv", "作品.csv"]);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -436,6 +446,61 @@ mod tests {
assert_eq!(vparent("/"), None);
}
#[test]
fn listings_keep_folders_first_and_stable_unicode_order() {
let mut tree = VirtualTree::default();
for name in ["作品.csv", "ä.csv", "Ä.csv", "İ.csv", "B.csv", "a.csv", "A.csv"] {
tree.add_file(&format!("/Shoot/{name}"), &format!("/files/{name}"), 1, None);
}
for name in ["zFolder", "Alpha"] {
tree.add_dir(&format!("/Shoot/{name}"));
}
let entries = tree.list("/Shoot", true).unwrap();
assert_eq!(
entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
["Alpha", "zFolder", "A.csv", "a.csv", "B.csv", "İ.csv", "Ä.csv", "ä.csv", "作品.csv"]
);
assert_eq!(entries[2].path, "/files/A.csv");
let mut reversed = entries.iter().rev().cloned().collect::<Vec<_>>();
sort_entries(&mut reversed);
assert_eq!(
reversed.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
["Alpha", "zFolder", "a.csv", "A.csv", "B.csv", "İ.csv", "ä.csv", "Ä.csv", "作品.csv"]
);
}
#[test]
#[ignore = "Media Browser filename sorting benchmark"]
fn directory_sort_benchmark() {
let entries: Vec<_> = (0..1000)
.map(|i| Entry {
name: format!("作品 {} {:04}.mov", if i % 2 == 0 { "Clip" } else { "clip" }, i * 7919 % 1000),
path: format!("/Shoot/{i}.mov"),
is_dir: i % 7 == 0,
size: 1,
modified: None,
kind: Some("video"),
})
.collect();
let mut before = Vec::new();
let mut after = Vec::new();
for _ in 0..5 {
let mut old = entries.clone();
let start = std::time::Instant::now();
old.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
before.push(start.elapsed());
let mut new = entries.clone();
let start = std::time::Instant::now();
sort_entries(&mut new);
after.push(start.elapsed());
assert_eq!(old, new);
std::hint::black_box(new);
}
before.sort();
after.sort();
println!("1,000 filename sort, median: {:?} -> {:?}", before[2], after[2]);
}
#[test]
fn iso_dates() {
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
+9 -2
View File
@@ -362,8 +362,15 @@ impl Session {
// Settings ▸ Export ▸ Default Output Folder, else the project's folder.
let folder = self.prefs.export.default_output_folder.trim();
let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder));
let base =
base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()));
// A project saved under a bare filename has the empty path as its parent. It sits in the
// working directory, so resolve against that rather than falling through to the
// unsaved-project default below (#1427).
let base = base.or_else(|| {
self.path
.as_deref()
.and_then(|s| std::path::Path::new(s).parent())
.map(|d| if d.as_os_str().is_empty() { std::path::PathBuf::from(".") } else { d.to_path_buf() })
});
// Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory
// is `/` for an app started from the macOS Finder, where nothing can be written (#446).
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from);
+22
View File
@@ -280,6 +280,28 @@ fn import_export_helpers() {
assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]);
}
#[test]
fn render_queue_resolves_a_bare_project_filename_against_the_working_directory() {
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
let mut s = session();
let cid = s.active_comp_id().unwrap();
let mut item = RenderQueueItem::new(1, cid);
item.output.output = "clip.mp4".into();
item.output.format = OutputFormat::H264;
// `--project ./proj.ecproj`: the parent is `.`, so the output goes beside the project.
s.path = Some("./proj.ecproj".into());
let dotted = s.resolve_output(&item).unwrap();
// `--project proj.ecproj`: the parent is the empty path, which must mean the same folder
// rather than the unsaved-project default (#1427).
s.path = Some("proj.ecproj".into());
let bare = s.resolve_output(&item).unwrap();
assert_eq!(PathBuf::from(&bare), PathBuf::from(&dotted), "a bare filename resolved to {bare} instead of {dotted}");
assert_eq!(PathBuf::from(&bare), PathBuf::from("./clip.mp4"));
}
#[test]
fn render_queue_uses_default_folder_bit_depth_names_and_segments() {
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
+43 -1
View File
@@ -433,8 +433,10 @@ fn copy_a_selected_property_pastes_onto_the_same_property() {
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
assert_eq!(s.state.selected_keys.len(), 2);
let transform = layer(&s, a).props.sub("transform").unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap();
s.execute("prop.select", json!({"layer": a, "prop": transform, "selectKeys": false})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("time.set", json!({"time": 2.0})).unwrap();
@@ -706,6 +708,21 @@ fn sequence_layers() {
assert_eq!(layer(&s, c).props.prop("transform/opacity").unwrap().keys.len(), 2);
}
/// File ▸ Dependencies ▸ Find Missing Fonts lists a text layer only when no face matches its
/// font by family, PostScript or full name.
#[test]
fn fonts_named_by_their_postscript_or_full_name_are_not_missing() {
let mut s = comp();
// The first three name bundled faces by family, PostScript and full name; no face has the fourth.
let fonts = ["Noto Serif", "Inter-Bold", "Inter SemiBold", "No Such Font Family 123"];
let ids: Vec<u64> = fonts.iter().map(|f| s.execute("layer.newText", json!({"text": "Aa", "font": f})).unwrap()["layer"].as_u64().unwrap()).collect();
let r = s.execute("file.findMissing", json!({"what": "fonts"})).unwrap();
let listed: Vec<(u64, &str)> = r["fonts"].as_array().unwrap().iter().map(|f| (f["layer"].as_u64().unwrap(), f["font"].as_str().unwrap())).collect();
assert_eq!(listed, [(ids[3], fonts[3])], "{r}");
let f = s.execute("text.fontFeatures", json!({"layer": ids[1]})).unwrap();
assert_eq!((&f["family"], &f["style"], &f["missing"]), (&json!("Inter"), &json!("Bold"), &json!(false)), "{f}");
}
#[test]
fn file_menu_project_ops() {
let mut s = comp();
@@ -1556,6 +1573,31 @@ fn interpret_footage_loop_extends_video_layers() {
assert_eq!(s.project.item(effectcraft_project::ItemId(p)).unwrap().duration(), Some(Tick::from_seconds_f64(6.0)));
}
/// #1068: the same for an interpreted PNG sequence — the loop count survives interpretation and
/// a freshly dragged layer plays every cycle.
#[test]
fn interpret_footage_loop_extends_sequence_layers() {
let mut s = comp();
let p = s.execute("file.importPlaceholder", json!({"name": "Seq", "width": 320, "height": 240, "duration": 0.4})).unwrap()["item"].as_u64().unwrap();
let id = effectcraft_project::ItemId(p);
// Make it a sequence like the importer's grouped PNGs.
s.edit("Seq", None, |proj, _| {
if let Some(ItemKind::Footage(f)) = proj.item_mut(id).map(|i| &mut i.kind) {
f.kind = effectcraft_project::FootageKind::Sequence;
f.sequence = (0..12).map(|n| format!("/tmp/seq_{n:04}.png")).collect();
f.sync_sequence_duration();
}
Ok(())
})
.unwrap();
s.state.project_selection = vec![id];
s.execute("file.interpretFootage", json!({"loop": 15.0})).unwrap();
let l = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap();
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
let dur = (layer.out_point - layer.in_point).seconds();
assert!((dur - 6.0).abs() < 0.05, "15 loops of a 0.4 s sequence: {dur}");
}
#[test]
fn percentage_guides_follow_the_comp_size() {
let mut s = comp();
+58
View File
@@ -164,6 +164,64 @@ fn presets_apply_render_and_round_trip() {
assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string()));
}
#[test]
fn cached_text_presets_keep_the_catalog_and_application_independent() {
let expected = [
("typewriter", "Typewriter"),
("fadeUpCharacters", "Fade Up Characters"),
("bounceInWords", "Bounce In Words"),
("trackingIn", "Tracking In"),
("scramble", "Scramble"),
("blurIn", "Blur In"),
("jitter", "Jitter"),
("dropInLines", "Drop In Lines"),
];
let (mut s, layer) = session();
let catalog = s.execute("text.presets", json!({})).unwrap();
let mut returned = catalog.clone();
returned[0]["name"] = json!("Edited copy");
let project = s.project.clone();
for _ in 0..2 {
let names = crate::text_presets();
assert_eq!(names.iter().map(|(id, name)| (id.as_str(), name.as_str())).collect::<Vec<_>>(), expected);
for (id, _) in expected {
let result = s.execute("layer.applyTextPreset", json!({"layer": layer, "preset": id})).unwrap();
assert!(!result["animators"].as_array().unwrap().is_empty(), "{id}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project, project, "{id}: undo restores the document");
}
assert_eq!(s.execute("text.presets", json!({})).unwrap(), catalog);
}
}
#[test]
#[ignore = "warm text-preset listing benchmark"]
fn text_preset_listing_benchmark() {
let old = || {
let v: serde_json::Value = serde_json::from_str(include_str!("../presets/text_animators.json")).unwrap();
let presets = v["presets"].as_array().unwrap().clone();
presets.iter().map(|p| (p["id"].as_str().unwrap().to_string(), p["name"].as_str().unwrap().to_string())).collect::<Vec<_>>()
};
assert_eq!(old(), crate::text_presets());
let mut before = Vec::new();
let mut after = Vec::new();
for _ in 0..5 {
let start = std::time::Instant::now();
for _ in 0..1000 {
std::hint::black_box(old());
}
before.push(start.elapsed());
let start = std::time::Instant::now();
for _ in 0..1000 {
std::hint::black_box(crate::text_presets());
}
after.push(start.elapsed());
}
before.sort();
after.sort();
println!("Text preset names, median per warm lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
}
#[test]
fn menus_have_text_animation_entries() {
let (s, _) = session();
+79
View File
@@ -108,6 +108,85 @@ fn keyframe_copy_paste_keeps_relative_timing_and_crosses_properties() {
assert!(e.is_err() || prop(&s, l2, "transform/position").keys.is_empty());
}
#[test]
fn copy_selected_keys_excludes_deselected_properties_and_stale_values() {
let (mut s, source) = setup();
s.execute("prop.set", json!({"layer": source, "path": "transform/opacity", "value": 25})).unwrap();
select_prop_keys(&mut s, source, "transform/opacity");
s.execute("edit.copy", json!({})).unwrap();
assert_eq!(s.state.value_clipboard.len(), 1);
animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]);
animate(&mut s, source, "transform/rotation", &[(0.0, json!(15)), (1.0, json!(90))]);
animate(&mut s, source, "transform/scale", &[(0.0, json!([50, 60, 100])), (1.0, json!([150, 160, 100]))]);
select_prop_keys(&mut s, source, "transform/scale");
s.execute(
"keys.select",
json!({"keys": [{"layer": source, "path": "transform/position", "time": 0}, {"layer": source, "path": "transform/rotation", "time": 1}], "add": true, "selectProperties": true}),
)
.unwrap();
s.execute(
"keys.select",
json!({"keys": [{"layer": source, "path": "transform/scale", "time": 0}, {"layer": source, "path": "transform/scale", "time": 1}], "toggle": true, "selectProperties": true}),
)
.unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
assert_eq!(s.state.selected_keys.len(), 2);
assert_eq!(s.state.selected_props.len(), 3);
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!(2));
assert!(s.state.value_clipboard.is_empty());
let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
s.execute("time.set", json!({"time": 2})).unwrap();
s.execute("layer.select", json!({"layers": [target]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
undo_redo_roundtrip(
&mut s,
|s| {
for (path, time, value) in [("transform/position", 2.0, KV::Vec3([10.0, 20.0, 0.0])), ("transform/rotation", 3.0, KV::Scalar(90.0))] {
let p = prop(s, target, path);
assert_eq!(p.keys.len(), 1);
let k = &p.keys[0];
assert_eq!((k.time.seconds(), &k.value), (time, &value));
assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier));
assert_eq!(k.in_ease[0], effectcraft_keyframe::Ease::EASY);
assert_eq!(k.out_ease[0], effectcraft_keyframe::Ease::EASY);
}
assert_eq!(s.state.selected_keys.len(), 2);
assert_eq!(prop(s, target, "transform/scale").value, KV::Vec3([100.0; 3]));
assert!(prop(s, target, "transform/scale").keys.is_empty());
assert_eq!(prop(s, target, "transform/opacity").value, KV::Scalar(100.0));
},
|s| {
assert!(prop(s, target, "transform/position").keys.is_empty());
assert!(prop(s, target, "transform/rotation").keys.is_empty());
},
);
}
#[test]
fn copy_mixed_animated_and_static_properties_keeps_the_static_value() {
let (mut s, source) = setup();
animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]);
s.execute("prop.set", json!({"layer": source, "path": "transform/rotation", "value": 45})).unwrap();
select_prop_keys(&mut s, source, "transform/position");
s.execute("prop.select", json!({"layer": source, "path": "transform/rotation", "add": true})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!({"keys": 2, "values": 1}));
let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
s.execute("time.set", json!({"time": 2})).unwrap();
s.execute("layer.select", json!({"layers": [target]})).unwrap();
let undo = s.history.undo.len();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1);
assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(45.0));
let position = prop(&s, target, "transform/position");
assert_eq!(
position.keys.iter().map(|k| (k.time.seconds(), k.value.clone())).collect::<Vec<_>>(),
vec![(2.0, KV::Vec3([10.0, 20.0, 0.0])), (3.0, KV::Vec3([30.0, 40.0, 0.0]))]
);
s.execute("edit.undo", json!({})).unwrap();
assert!(prop(&s, target, "transform/position").keys.is_empty());
assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(0.0));
}
#[test]
fn cut_removes_keys_and_paste_restores_them() {
let (mut s, l) = setup();
+18 -8
View File
@@ -271,7 +271,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::
Some(
effectcraft_pdf::parse_page(bytes, page as usize)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
None => d,
})
.map_err(|e| format!("{path}: {e}")),
@@ -279,7 +279,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::
}
/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the
/// page: one footage item per file layer (each showing only that layer, continuously
/// page: one footage item per file layer (optionally cropped to its artwork, continuously
/// rasterisable), top layer first. Returns (comp, folder, items).
pub fn import_vector_comp(
proj: &mut Project,
@@ -287,11 +287,12 @@ pub fn import_vector_comp(
bytes: &[u8],
name: &str,
page: u32,
rate: effectcraft_time::FrameRate,
duration: effectcraft_time::Tick,
retain: bool,
timing: (effectcraft_time::FrameRate, effectcraft_time::Tick),
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
let (rate, duration) = timing;
let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?;
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
let (w, h) = doc.pixel_size();
@@ -301,24 +302,33 @@ pub fn import_vector_comp(
let mut items = vec![];
let names = effectcraft_pdf::layer_names(&doc);
for (i, lname) in names.iter().enumerate() {
let (size, offset) = if retain {
let (layer, offset) = effectcraft_pdf::layer_doc_with_sizes(&doc, i, true);
(layer.pixel_size(), offset)
} else {
((w, h), [0.0, 0.0])
};
let f = Footage {
path: path.to_string(),
kind: FootageKind::Still,
width: w,
height: h,
width: size.0,
height: size.1,
pixel_aspect: 1.0,
frame_rate: rate,
has_video: true,
alpha: AlphaMode::Straight,
loop_count: 1,
codec: codec.into(),
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }),
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: retain, ..Default::default() }),
page,
..Default::default()
};
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
items.push(id);
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None);
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, size, None);
if retain && let Some(position) = l.props.prop_mut("transform/position") {
position.value = Value::Vec3([offset[0] + f64::from(size.0) / 2.0, offset[1] + f64::from(size.1) / 2.0, 0.0]);
}
l.name = comp.unique_layer_name(lname);
comp.layers.insert(0, l);
}
+1 -1
View File
@@ -362,7 +362,7 @@ const MAX_COLLAPSED_DEPTH: usize = 8;
fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterator<Item = &'a Layer> {
let comp = ctx.comp;
let time = ctx.time;
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time));
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
}
+25
View File
@@ -613,3 +613,28 @@ fn wav_and_aiff_audio_only() {
}
}
}
/// An AIFF EffectCraft renders imports back (#1428): plain 16-bit and AIFF-C `fl32`.
#[test]
fn rendered_aiff_imports_back() {
use effectcraft_export::render_queue::AudioFormat;
let d = out_dir("aiff-import");
let wav = d.join("tone.wav");
write_tone(&wav, 2.0);
let (p, cid) = project(Some(&wav));
let pool = MediaPool::new();
for (format, name) in [(AudioFormat::S16, "s16.aif"), (AudioFormat::F32, "f32.aif")] {
// 44.1 kHz, not the 48 kHz the samples are read back at: a wrong rate shows in the duration.
let om = OutputModule { audio_sample_rate: 44_100, audio_channels: 2, audio_format: format, ..OutputModule::for_format(OutputFormat::Aiff) };
let path = d.join(name);
assert!(run(&p, cid, &pool, &om, &path).audio);
let f = effectcraft_media::probe(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(f.has_audio && !f.has_video, "{name}");
assert_eq!(f.duration, Tick::from_seconds_f64(1.2), "{name}: 52 920 frames at 44.1 kHz");
// Tone on the left, silence on the right: the two channels are read as two.
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied()));
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
assert!(r < 0.01, "{name}: right RMS {r}");
}
}
+13 -7
View File
@@ -917,6 +917,12 @@ impl GpuContext {
Some(g)
}
/// Entries in the layer upload cache.
#[cfg(test)]
pub(crate) fn upload_entries(&self) -> usize {
self.uploads.lock().map(|u| u.map.len()).unwrap_or_default()
}
/// Upload a (layer cache) buffer, reusing the texture while the same `Arc` is alive: static
/// layers upload once, not every frame.
pub(crate) fn upload_buf(&self, buf: &Arc<Buf>) -> Option<GpuImage> {
@@ -939,14 +945,14 @@ impl GpuContext {
u.bytes -= old.bytes;
}
u.bytes += bytes;
// Drop textures of freed buffers, then the least recently used over budget.
// Drop textures of freed buffers at once (a layer whose buffer changes every frame
// makes a new one each time, and nothing can hit the old one again), then the least
// recently used over budget.
let budget = self.upload_budget();
if u.bytes > budget {
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
for k in dead {
if let Some(e) = u.map.remove(&k) {
u.bytes -= e.bytes;
}
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
for k in dead {
if let Some(e) = u.map.remove(&k) {
u.bytes -= e.bytes;
}
}
if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
+4 -2
View File
@@ -510,8 +510,10 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3<u32>) {
let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0);
c += vec3<f32>(n);
}
let op = P.f[8].z;
textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4<f32>(c * op, op), P.u[0].x));
let o = textureLoad(src, p, 0);
// None keeps the layer's alpha, as the CPU path does (#1381).
let op = select(P.f[8].z, P.f[8].z * o.w, P.u[0].x == 255u);
textureStore(out, p, gen_blend(o, vec4<f32>(c * op, op), P.u[0].x));
}
// ---------------------------------------------------------------- Noise / Add Grain
+16
View File
@@ -1044,3 +1044,19 @@ fn static_effect_layers_draw_from_the_cache() {
assert!(frames[1] == frames[2]);
check("static effect layer vs CPU", compare_at(&s, opts(), t(2)), 0.0);
}
/// The layer upload cache drops the textures of freed buffers when it inserts, not only once
/// it is over budget (an animated layer makes a new buffer every frame).
#[test]
fn upload_cache_drops_textures_of_freed_buffers() {
let Some(g) = gpu() else { return };
let img = effectcraft_raster::Image::new(256, 256);
let before = g.ctx.upload_entries();
for _ in 0..6 {
let buf = std::sync::Arc::new(effectcraft_effects::Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 });
let up = g.ctx.upload_buf(&buf).expect("upload");
drop((up, buf));
}
let kept = g.ctx.upload_entries();
assert!(kept <= before + 1, "the upload cache kept {kept} textures of freed buffers (had {before})");
}
+22
View File
@@ -203,6 +203,28 @@ fn echo_and_posterize_time() {
}
}
#[test]
fn echo_after_star_burst_preserves_the_stars() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for operator in 0..7 {
for posterize in [false, true] {
let mut s = Scene::new(depth);
let mut l = s.solid([1.0; 3], 97, 61);
s.effect(&mut l, "ec.sim.ccstarburst", &[("gridSpacing", n(8.0)), ("size", n(40.0))]);
s.effect(&mut l, "ec.time.echo", &[("numberOfEchoes", n(2.0)), ("echoTime", n(-0.1)), ("decay", n(0.6)), ("echoOperator", e(operator))]);
if posterize {
s.effect(&mut l, "ec.time.posterizetime", &[("frameRate", n(4.0))]);
}
s.push(l);
let t = Tick::from_seconds_f64(0.6);
let label = format!("Star Burst + Echo {operator} posterize={posterize} {depth:?}");
check(&label, compare_at(&s, opts(), t), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0);
}
}
}
}
#[test]
fn median_levels_match_cpu_division() {
let Some(g) = crate::tests::gpu() else { return };
+3
View File
@@ -73,6 +73,9 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut
let levels = r.pipe().levels;
let mut i = 0;
while i < visible.len() {
// A device that ran out of memory (or was lost) on an earlier layer fails every later
// one: give the frame back to the CPU now instead of drawing the rest of the comp.
e.g.check_health().ok()?;
if visible[i].is_3d() {
let mut j = i;
while j < visible.len() && visible[j].is_3d() {
+2 -1
View File
@@ -290,7 +290,8 @@ mod tests {
let mut s = super::session();
s.exporter = Some(Arc::new(super::FileExporter { sink: Some(Arc::new(move |p: &str, d: Vec<u8>| g.lock().unwrap().push((p.to_string(), d)))) }));
s.execute("file.openDemoProject", json!({})).unwrap();
let dir = "/no-such-dir-effectcraft-sink-test";
// Rooted without a drive letter is not absolute on Windows, where the engine joins it to the home drive.
let dir = if cfg!(windows) { "C:/no-such-dir-effectcraft-sink-test" } else { "/no-such-dir-effectcraft-sink-test" };
s.execute(
"renderQueue.add",
json!({"format": "gif", "output": format!("{dir}/a.gif"), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2}),
+2 -2
View File
@@ -38,14 +38,14 @@ fn replacing_a_missing_file_relinks_the_others_that_moved_with_it() {
let before = footage_paths(&s);
assert_eq!(before.len(), 2, "{before:?}");
// The job folder moves; both files go missing.
let new = root.join("Archive/Job");
let new = root.join("Archive").join("Job");
std::fs::create_dir_all(root.join("Archive")).unwrap();
std::fs::rename(&old, &new).unwrap();
let r = s.execute("footage.check", json!({"wait": true})).unwrap();
assert_eq!(r["missing"], 2, "{r}");
// Locating one finds the other.
let a = before.iter().find(|(_, p, _)| p.ends_with("a.png")).unwrap().0;
let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills/a.png").to_string_lossy()})).unwrap();
let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills").join("a.png").to_string_lossy()})).unwrap();
assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}");
let after = footage_paths(&s);
assert!(after.iter().all(|(_, p, missing)| p.starts_with(&*new.to_string_lossy()) && !missing), "{after:?}");
+91 -17
View File
@@ -260,23 +260,25 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() {
// Import As: Composition — one layer per file layer, top first, together like the page.
let path = tmp("layers.ai");
std::fs::write(&path, &bytes).unwrap();
let mut s = effectcraft_host::session();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
let cid = ItemId(r["comps"][0].as_u64().unwrap());
let comp = s.project.comp(cid).unwrap();
let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, vec!["Art", "Sky"]);
assert_eq!((comp.width, comp.height), (120, 80));
let art = comp.layers[0].id.0;
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
let d = mean_diff(&img, &direct);
assert!(d < 1.0 / 255.0, "layered comp vs page {d}");
// Each layer's footage shows only its layer.
s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap();
let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
assert_eq!(sky.get(5, 5), img.get(5, 5));
let p = sky.get(60, 30);
assert!(p[2] > p[0], "only sky under the art {p:?}");
for import_as in ["composition", "compositionLayerSizes"] {
let mut s = effectcraft_host::session();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap();
let cid = ItemId(r["comps"][0].as_u64().unwrap());
let comp = s.project.comp(cid).unwrap();
let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, vec!["Art", "Sky"]);
assert_eq!((comp.width, comp.height), (120, 80));
let art = comp.layers[0].id.0;
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
let d = mean_diff(&img, &direct);
assert!(d < 1.0 / 255.0, "layered comp vs page {d}");
// Each layer's footage shows only its layer.
s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap();
let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
assert_eq!(sky.get(5, 5), img.get(5, 5));
let p = sky.get(60, 30);
assert!(p[2] > p[0], "only sky under the art {p:?}");
}
}
#[test]
@@ -522,3 +524,75 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() {
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}");
}
}
fn retain_pdf(width: u32) -> Vec<u8> {
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> >> /Contents 4 0 R >>".into(), None),
(
4,
"<< >>".into(),
Some(
format!("/OC /L0 BDC q 20 15 {width} 20 re W n 1 0 0 rg 0 0 120 80 re f Q EMC /OC /L1 BDC 0 0 1 RG 4 w 70 40 m 100 40 l S EMC")
.into_bytes(),
),
),
(5, "<< /Type /OCG /Name (Box) >>".into(), None),
(6, "<< /Type /OCG /Name (Line) >>".into(), None),
],
1,
)
}
#[test]
fn ai_composition_retains_layer_sizes_and_positions() {
use effectcraft_project::{ItemKind, LayerSource, Project};
use effectcraft_time::Tick;
for name in ["retain.ai", "retain.pdf"] {
let path = tmp(name);
let bytes = retain_pdf(30);
std::fs::write(&path, &bytes).unwrap();
let mut s = effectcraft_host::session();
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let cid = ItemId(r["comps"][0].as_u64().unwrap());
let comp = s.project.comp(cid).unwrap();
assert_eq!((comp.width, comp.height), (120, 80));
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Line", "Box"]);
let mut ids = vec![];
for (name, size, anchor, position) in [("Box", (30, 20), [15.0, 10.0], [35.0, 55.0]), ("Line", (30, 4), [15.0, 2.0], [85.0, 40.0])] {
let l = comp.layer_by_name(name).unwrap();
let LayerSource::Footage { item } = l.source else { panic!("footage") };
let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") };
assert_eq!((f.width, f.height), size, "{name} uses its artwork bounds");
assert!(f.layer.as_ref().unwrap().layer_size);
assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), anchor);
assert_eq!(l.props.prop("transform/position").unwrap().value.as_vec2(), position);
let frame = s.footage.frame(item, f, Tick::ZERO).unwrap();
assert_eq!((frame.width, frame.height), size);
let sharp = s.footage.vector_frame(item, f, 2.0).unwrap();
assert_eq!((sharp.width, sharp.height), (size.0 * 2, size.1 * 2));
assert!(sharp.get(1, 1)[3] > 0.99);
ids.push(item);
}
let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 120, 80, 1.0);
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001);
let saved = s.project.to_json();
s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap());
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "reopened project");
let box_id = s.active_comp().unwrap().layer_by_name("Box").unwrap().id.0;
s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [box_id]})).unwrap();
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "shape conversion preserves placement");
s.execute_checked("edit.undo", json!({})).unwrap();
std::fs::write(&path, retain_pdf(40)).unwrap();
s.execute_checked("file.reloadFootage", json!({"items": ids})).unwrap();
let ItemKind::Footage(f) = &s.project.item(ids[0]).unwrap().kind else { panic!("footage") };
assert_eq!((f.width, f.height), (40, 20), "reload refreshes the layer's own dimensions");
let frame = s.footage.frame(ids[0], f, Tick::ZERO).unwrap();
assert_eq!((frame.width, frame.height), (40, 20));
assert!(frame.get(39, 10)[0] > 0.99, "reload invalidates cached pixels");
}
}
+28
View File
@@ -0,0 +1,28 @@
//! A WebM rendered by EffectCraft and imported back reports its real frame count (#1429). Matroska
//! stores the duration in whole milliseconds, so at 30 fps a 1-frame file lasts 33 ms, just short
//! of one 33.33 ms frame.
use serde_json::json;
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-host-webm-meta-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
#[test]
fn a_webm_at_30_fps_reports_every_frame() {
for n in [1u32, 4] {
let mut s = effectcraft_host::session();
let out = tmp(&format!("frames-{n}.webm"));
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"name": "BG", "color": "#142850"})).unwrap();
let span = json!({"comp": "Main", "format": "webm", "output": out, "timeSpan": "custom", "start": 0, "duration": n as f64 / 30.0});
s.execute("renderQueue.add", span).unwrap();
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
let r = s.execute("file.import", json!({"paths": [out.clone()]})).unwrap();
let m = s.execute("item.metadata", json!({"item": r["items"][0]})).unwrap();
let _ = std::fs::remove_file(&out);
assert_eq!(m["item"]["frames"], json!(n), "{n} rendered frames: {m}");
}
}
+2
View File
@@ -24,6 +24,8 @@ filmcraft-isobmff = { workspace = true }
filmcraft-frame = { workspace = true }
filmcraft-color = { workspace = true }
filmcraft-time = { workspace = true }
# AIFF import (#1428): enables the aiff reader of filmcraft-codecs' symphonia.
symphonia = { workspace = true }
exr = { workspace = true }
gif = { workspace = true }
image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] }
+1 -1
View File
@@ -28,7 +28,7 @@ pub fn vector_doc(
Some(
effectcraft_pdf::parse_page(bytes, page as usize)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
None => d,
})
.map_err(|e| format!("{path}: {e}")),
+2 -4
View File
@@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result<Model> {
let Some(key) = it.next() else { continue };
let bad = || perr(format!("STL line {}: expected three numbers", ln + 1));
match key.to_ascii_lowercase().as_str() {
"solid" => {
if name.is_none() {
name = Some(line.get(5..).unwrap_or("").trim().to_string());
}
"solid" if name.is_none() => {
name = Some(line.get(5..).unwrap_or("").trim().to_string());
}
"facet" => {
// `facet normal nx ny nz`
+3 -1
View File
@@ -4,7 +4,9 @@ Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, wh
documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG
(`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape
layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one
layer per file layer (File ▸ Import ▸ Composition).
layer per file layer (File ▸ Import ▸ Composition). Composition - Retain Layer Sizes uses each
layer's artwork bounds and keeps its placement on the page. Images keep their rectangular bounds;
clipping paths constrain the bounds, while soft masks do not change them.
Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file
structure, filters, content streams, graphics state, colour spaces, functions, shadings, text,
+38 -4
View File
@@ -533,6 +533,10 @@ impl Cff {
/// A glyph's outline in glyph space (`font_matrix` maps it to text space) and its advance.
pub fn outline(&self, gid: u16) -> Option<(BezPath, f64)> {
self.outline_depth(gid, 0)
}
fn outline_depth(&self, gid: u16, depth: usize) -> Option<(BezPath, f64)> {
let r = self.charstrings.get(gid as usize)?.clone();
let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize;
let fd = fd.min(self.lsubrs.len() - 1);
@@ -547,13 +551,17 @@ impl Cff {
_ => self.widths[fd].0,
};
let mut path = st.path;
// endchar seac: base and accent glyphs by standard code.
if let Some((adx, ady, b, a)) = st.seac {
// endchar seac: base and accent glyphs by standard code. The nesting is bounded like the
// Type 1 interpreter's: a font whose accented glyphs seac into each other (or into
// themselves) must not recurse until the stack overflows.
if let Some((adx, ady, b, a)) = st.seac
&& depth < 2
{
let find = |code: u8| crate::encoding::Base::Standard.name(code).and_then(|n| self.gid_by_name(&n));
if let Some((bp, _)) = find(b).and_then(|g| self.outline(g)) {
if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1)) {
path.extend(bp);
}
if let Some((ap, _)) = find(a).and_then(|g| self.outline(g)) {
if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1)) {
path.extend(kurbo::Affine::translate((adx, ady)) * ap);
}
}
@@ -1048,4 +1056,30 @@ mod tests {
assert!((p.area().abs() - 250_000.0).abs() < 1e-6, "{}", p.area());
assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0));
}
/// An `endchar` seac whose base and accent name its own glyph used to recurse until the
/// stack overflowed and aborted the app; the depth limit cuts it off like the Type 1
/// interpreter's.
#[test]
fn seac_cycles_are_cut_off_not_a_stack_overflow() {
let enc = |v: i32| -> Vec<u8> {
if (-107..=107).contains(&v) {
vec![(v + 139) as u8]
} else {
let mut o = vec![28];
o.extend_from_slice(&(v as i16).to_be_bytes());
o
}
};
// `w adx ady bchar achar endchar`: both components are 'A' (standard code 65), this glyph.
let mut cs = vec![];
for v in [500, 0, 0, 65, 65] {
cs.extend(enc(v));
}
cs.push(14);
let font = write_test_cff(&[(".notdef", vec![14]), ("A", cs)]);
let cff = Cff::parse(&font).unwrap();
let (p, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap();
assert!(p.elements().is_empty());
}
}
+12 -4
View File
@@ -208,10 +208,10 @@ pub fn name_to_unicode(name: &str) -> Option<char> {
if let Some((_, u)) = NAMES.iter().find(|(n, _)| *n == base) {
return char::from_u32(*u);
}
if let Some(h) = base.strip_prefix("uni")
&& h.len() >= 4
{
return u32::from_str_radix(&h[..4], 16).ok().and_then(char::from_u32);
// `get(..4)` also insists on a character boundary: a name like `uni\u{20AC}\u{20AC}` used to
// panic slicing inside a multi-byte character.
if let Some(x) = base.strip_prefix("uni").and_then(|h| h.get(..4)) {
return u32::from_str_radix(x, 16).ok().and_then(char::from_u32);
}
if let Some(h) = base.strip_prefix('u')
&& (4..=6).contains(&h.len())
@@ -386,4 +386,12 @@ mod tests {
assert_eq!(unicode_to_name('Ω'), "Omega");
assert_eq!(unicode_to_name('中'), "uni4E2D");
}
/// A glyph name like `uni\u{20AC}\u{20AC}` (multi-byte UTF-8 after `uni`) must not slice
/// inside a character.
#[test]
fn multibyte_glyph_names_do_not_panic() {
assert_eq!(name_to_unicode("uni\u{20AC}\u{20AC}"), None);
assert_eq!(name_to_unicode("uni\u{20AC}"), None);
}
}
+4 -1
View File
@@ -82,7 +82,10 @@ pub(crate) fn inline_len(file: &File, d: &Dict, res: Option<&Dict>) -> Option<us
let mask = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
let bpc = if mask { 1 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize };
let n = if mask { 1 } else { file.get(d, "ColorSpace").map(|c| color_space(file, c, res).components()).unwrap_or(1) };
Some((w * n * bpc).div_ceil(8) * h)
// `Width`/`Height` come straight from the file and can be enormous: fall back to the
// `EI` scan (the caller's `None` branch) instead of overflowing the arithmetic.
let row = w.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8);
row.checked_mul(h)
}
/// Decode an image's samples (all filters, including DCT). Returns (bytes, components from
+21
View File
@@ -195,6 +195,27 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc {
d
}
/// One layer, optionally cropped to its artwork, and its original document-space top-left.
pub fn layer_doc_with_sizes(doc: &Doc, index: usize, retain: bool) -> (Doc, [f64; 2]) {
let mut d = layer_doc(doc, index);
if !retain {
return (d, [0.0, 0.0]);
}
let page = kurbo::Rect::new(0.0, 0.0, doc.width, doc.height);
let bounds = d.artwork_bounds().map(|b| b.intersect(page)).filter(|b| b.width() > 0.0 && b.height() > 0.0);
let (x, y, w, h) = match bounds {
Some(b) => {
let (x, y) = (b.x0.floor(), b.y0.floor());
(x, y, b.x1.ceil() - x, b.y1.ceil() - y)
}
None => (0.0, 0.0, 1.0, 1.0),
};
d.width = w;
d.height = h;
d.root.transform = Affine::translate((-x, -y)) * d.root.transform;
(d, [x, y])
}
#[cfg(test)]
mod tests;
#[cfg(test)]
+1 -1
View File
@@ -515,7 +515,7 @@ impl<'a> Interp<'a> {
&& (p + 2 == data.len() || crate::object::is_white(data[p + 2]))
};
let end = match crate::image::inline_len(self.file, &d, Some(res)) {
Some(len) => (start + len).min(data.len()),
Some(len) => start.saturating_add(len).min(data.len()),
None => {
let mut p = start;
while p + 1 < data.len() && !is_ei(p) {
+45
View File
@@ -1045,3 +1045,48 @@ fn multiple_pages_and_calculator_shadings() {
assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25));
assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2)));
}
#[test]
fn retained_layers_keep_strokes_clips_and_empty_layers() {
let doc = parse(&sample_pdf(false)).unwrap();
for i in 0..layer_names(&doc).len() {
let full = layer_doc(&doc, i);
let (cropped, offset) = layer_doc_with_sizes(&doc, i, true);
let (w, h) = cropped.pixel_size();
let image = effectcraft_svg::rasterize(&cropped, w, h, 1.0);
let original = effectcraft_svg::rasterize(&full, 200, 100, 1.0);
for y in 0..100 {
for x in 0..200 {
let a = image.get(x - offset[0] as i64, y - offset[1] as i64);
let b = original.get(x, y);
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5), "layer {i} at {x},{y}: {a:?} vs {b:?}");
}
}
}
let (empty, _) = layer_doc_with_sizes(&doc, usize::MAX, true);
assert_eq!(empty.pixel_size(), (1, 1));
}
#[test]
fn retained_layers_measure_transformed_artwork_without_rasterizing() {
let doc = effectcraft_svg::parse(br#"<svg width="120" height="80"><g transform="translate(60,0)"><rect x="10" y="20" width="30" height="10" transform="rotate(90)"/></g><path d="M 10 20 L 30 20" fill="none" stroke="red" stroke-width="4" stroke-linecap="square"/></svg>"#).unwrap();
let (rotated, offset) = layer_doc_with_sizes(&doc, 0, true);
assert_eq!(offset, [30.0, 10.0]);
assert_eq!(rotated.pixel_size(), (10, 30));
let (stroked, offset) = layer_doc_with_sizes(&doc, 1, true);
assert_eq!(offset, [8.0, 18.0]);
assert_eq!(stroked.pixel_size(), (24, 4));
}
#[test]
fn retained_layers_preserve_artwork_beyond_the_bounds_depth_limit() {
let mut doc = effectcraft_svg::parse(br#"<svg width="120" height="80"><rect x="20" y="15" width="30" height="20"/></svg>"#).unwrap();
for _ in 0..300 {
let mut group = Group::new("nested");
group.children = std::mem::take(&mut doc.root.children);
doc.root.children.push(Node::Group(group));
}
let (cropped, offset) = layer_doc_with_sizes(&doc, 0, true);
assert_eq!(cropped.pixel_size(), (120, 80), "unknown bounds keep the full page");
assert_eq!(offset, [0.0, 0.0]);
}
+16
View File
@@ -263,6 +263,22 @@ fn hostile_structures_return_errors_or_draw_nothing() {
assert!(!doc.skipped.is_empty());
}
#[test]
fn inline_image_with_absurd_dimensions_falls_back_to_ei_scan() {
// `/W 5000000000000000000` overflows the row math (`* bpc` alone exceeds usize::MAX):
// `inline_len` must return `None` (scan for `EI`) instead of overflowing; the oversized
// image itself is then refused by size.
let bytes = page_pdf("q 10 0 0 10 0 0 cm BI /W 5000000000000000000 /H 100 /CS /G /BPC 8 ID \0 EI Q", "", vec![]);
let doc = parse(&bytes).unwrap();
let _ = render(&doc);
assert!(!doc.skipped.is_empty());
// A length just under `usize::MAX` passes the row math; past byte 4096 of the content the
// caller's `start + len` would then overflow.
let content = format!("{}q 10 0 0 10 0 0 cm BI /W 2305843009213693440 /H 8 /CS /G /BPC 8 ID EI Q", " ".repeat(5000));
let doc = parse(&page_pdf(&content, "", vec![])).unwrap();
let _ = render(&doc);
}
#[test]
fn truncated_and_corrupt_eps_never_panic() {
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n\
+26 -6
View File
@@ -769,10 +769,14 @@ impl Crop {
if self.use_roi
&& let Some([x, y, rw, rh]) = self.roi
{
let l = (x * scale).round() as i32;
let t = (y * scale).round() as i32;
let r = w as i32 - ((x + rw) * scale).round() as i32;
let b = h as i32 - ((y + rh) * scale).round() as i32;
// The quad arrives unvalidated (control channel or a hand-edited project):
// clamp like the sibling edge fields (plus/minus 20 000) and subtract
// without overflow.
let conv = |v: f64| v.round().clamp(-20_000.0, 20_000.0) as i32;
let l = conv(x * scale);
let t = conv(y * scale);
let r = (w as i32).saturating_sub(conv((x + rw) * scale));
let b = (h as i32).saturating_sub(conv((y + rh) * scale));
return (t, l, b, r);
}
(self.top, self.left, self.bottom, self.right)
@@ -1094,8 +1098,8 @@ impl OutputModule {
/// scale `scale`: after Crop and Resize (before codec rounding).
pub fn frame_size(&self, w: u32, h: u32, scale: f64) -> (u32, u32) {
let (t, l, b, r) = self.crop.edges(w, h, scale);
let cw = (w as i32 - l - r).max(1) as u32;
let ch = (h as i32 - t - b).max(1) as u32;
let cw = (w as i32).saturating_sub(l).saturating_sub(r).max(1) as u32;
let ch = (h as i32).saturating_sub(t).saturating_sub(b).max(1) as u32;
if !self.resize.enabled {
return (cw, ch);
}
@@ -1643,6 +1647,22 @@ mod tests {
assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0));
}
#[test]
fn hostile_roi_cannot_overflow_crop_math() {
// `-1e12` saturates to `i32::MIN` on conversion: `w as i32 - l` must saturate,
// not overflow, and still yield a usable (degenerate) frame size.
let c = Comp::new(1920, 1080, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0));
let rs = RenderSettings::default();
let mut m = OutputModule::for_format(OutputFormat::PngSequence);
m.crop = Crop { enabled: true, use_roi: true, roi: Some([-1e12, -1e12, 200.0, 200.0]), ..Default::default() };
let (w, h) = m.output_size(&c, &rs);
assert!((w, h) == (1, 1), "degenerate but valid: {w}x{h}");
// Sane ROIs are unaffected (existing behavior).
m.crop = Crop { enabled: true, use_roi: true, roi: Some([100.0, 50.0, 800.0, 400.0]), ..Default::default() };
let half = RenderSettings { resolution: 0.5, ..Default::default() };
assert_eq!(m.output_size(&c, &half), (400, 200));
}
#[test]
fn uncompressed_quicktime_format() {
let f = OutputFormat::from_name("uncompressed").unwrap();
+18 -10
View File
@@ -216,7 +216,7 @@ struct FxHost<'r, 'a, 'c> {
/// whose effects run on the comp below).
origin: [f64; 2],
/// Index of the effect being rendered (bounds `self_at` to effects before it).
index: std::sync::atomic::AtomicUsize,
index: usize,
original: Option<&'c Buf>,
}
@@ -307,7 +307,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
if self.r.depth > MAX_FX_DEPTH || self.layer.switches.adjustment {
return None;
}
let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed));
let n = effects.min(self.index);
let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time));
let sub = self.r.nested();
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
@@ -337,8 +337,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
fn params_at(&self, layer_time: f64) -> Option<Params> {
let i = self.index.load(std::sync::atomic::Ordering::Relaxed);
let g = self.layer.effects()?.groups().nth(i)?;
let g = self.layer.effects()?.groups().nth(self.index)?;
let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time)));
Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr)))
}
@@ -1158,7 +1157,7 @@ impl<'a> Renderer<'a> {
buf
});
}
let host = FxHost { r: self, ctx, layer, origin, index: Default::default(), original: original.as_ref() };
let host = FxHost { r: self, ctx, layer, origin, index: 0, original: original.as_ref() };
let env = EffectEnv {
masks: &mask_shapes,
host: Some(&host),
@@ -1192,12 +1191,21 @@ impl<'a> Renderer<'a> {
{
let run: Vec<_> = stack[k..].iter().take_while(|(_, _, s)| s.gpu && a.supports_effect(s.id)).collect();
let params: Vec<Params> = run.iter().map(|(_, g, _)| self.effect_params(ctx, layer, g)).collect();
let hosts: Vec<_> = run.iter().map(|(i, _, _)| FxHost { index: *i, ..host }).collect();
let steps: Vec<FxStep> = run
.iter()
.zip(&params)
.map(|((i, g, s), params)| FxStep {
.zip(&hosts)
.map(|(((i, g, s), params), host)| FxStep {
spec: s,
ctx: EffectCtx { params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env: EffectEnv { effect_index: *i, ..env } },
ctx: EffectCtx {
params,
time: lt.seconds(),
layer_size,
seed: g.uid as u32,
adjustment,
env: EffectEnv { host: Some(host), effect_index: *i, ..env },
},
})
.collect();
let t0 = web_time::Instant::now();
@@ -1212,8 +1220,8 @@ impl<'a> Renderer<'a> {
}
k += 1;
let params = self.effect_params(ctx, layer, g);
host.index.store(i, std::sync::atomic::Ordering::Relaxed);
let env = EffectEnv { effect_index: i, ..env };
let host = FxHost { index: i, ..host };
let env = EffectEnv { host: Some(&host), effect_index: i, ..env };
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
let t0 = web_time::Instant::now();
target.cpu(&mut |buf| {
@@ -2086,7 +2094,7 @@ impl<'a> Renderer<'a> {
/// applied).
pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> {
let (comp, t) = (ctx.comp, ctx.time);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
comp.layers
.iter()
.rev()
+18
View File
@@ -953,3 +953,21 @@ fn shape_content_bounds_are_the_visual_edges() {
assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}");
}
}
/// Solo is not time-dependent (as in After Effects): while any layer is soloed, the others stay
/// excluded even when the playhead is outside the soloed layer's in-out range.
#[test]
fn solo_excludes_other_layers_outside_the_soloed_layers_range() {
let (mut p, cid, comp) = setup();
let a = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
let mut b = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
b.in_point = Tick::from_seconds_f64(1.0);
b.switches.solo = true;
let c = p.comp_mut(cid).unwrap();
c.layers.push(b);
c.layers.push(a);
let first = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
assert_eq!(first.get(100, 50), [0.0, 0.0, 0.0, 0.0], "neither A nor B before B's in point");
let second = render_frame(&p, cid, Tick::from_seconds_f64(1.5), 1.0);
assert_eq!(second.get(100, 50), [0.0, 0.0, 1.0, 1.0], "only B inside its range");
}
+1 -1
View File
@@ -873,7 +873,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item:
let saved = b.outer;
b.outer = saved * ctx.world_matrix(layer);
let t = nctx.time;
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
for l in nctx.comp.layers.iter().rev() {
if !l.is_active_at(t)
|| l.is_light()
+1 -1
View File
@@ -497,7 +497,7 @@ fn collapsed_items<'a>(
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] };
let t = nctx.time;
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
let nested: Vec<&'a Layer> = nctx
.comp
.layers
+1
View File
@@ -1978,6 +1978,7 @@ var app = {
savePrefAsBool: function () {},
savePrefAsFloat: function () {},
reload: function () {},
saveToDisk: function () {},
},
newProject: function () { __call("file.newProject", {}); return new Project(); },
open: function (file) {
+12 -7
View File
@@ -563,27 +563,32 @@ fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
let mut it = l[i..].split([':', ')']);
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
};
// The innermost frame in the user's script, or the error's own position.
let frames: Vec<&str> = msg.lines().skip(1).collect();
let frame = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick));
// `ReferenceError: name is not defined`: boa places it at the start of the function that reads
// the name, so the first use of the name in the code is the better position (#1063).
// the name, so the name's first use from that frame's line on is the better position (#1063).
// With no frame to start from, the first use anywhere in the code is (#1161).
let first = msg.lines().next().unwrap_or("");
let undefined = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next());
if let Some(at) = undefined.and_then(|name| first_use(name, code)) {
let start_line = frame.map_or(1, |(line, _)| line);
if let Some(at) = undefined.and_then(|name| first_use(name, code, start_line)) {
return (Some(at.0), Some(at.1));
}
let frames: Vec<&str> = msg.lines().skip(1).collect();
if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) {
if let Some((a, b)) = frame {
return (Some(a), Some(b));
}
(None, None)
}
/// The first use of `name` as a whole identifier in `code`, as a 1-based line and column.
fn first_use(name: &str, code: &str) -> Option<(u32, u32)> {
/// The first use of `name` as a whole identifier in `code` on or after `start_line` (1-based), as a
/// 1-based line and column.
fn first_use(name: &str, code: &str, start_line: u32) -> Option<(u32, u32)> {
if name.is_empty() {
return None;
}
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
for (n, line) in code.lines().enumerate() {
for (n, line) in code.lines().enumerate().skip(start_line.saturating_sub(1) as usize) {
let b = line.as_bytes();
let mut from = 0;
while let Some(i) = line.get(from..).and_then(|rest| rest.find(name)).map(|i| i + from) {
+51
View File
@@ -187,6 +187,18 @@ fn errors_inside_a_function_wrapper_report_the_failing_line() {
assert_eq!(e.line, Some(3), "{e:?}");
}
/// The first use of an undefined name is the one inside the function that reads it: a mention
/// before it, in a comment, a string or another function, doesn't move the report (#1161).
#[test]
fn an_undefined_name_mentioned_earlier_reports_its_use_in_the_reading_function() {
let mut s = session();
let code = "// nope is mentioned here\nvar text = 'nope';\nfunction other() { return nope; }\n(function () {\n var a = 1;\n nope.call();\n})();\n";
let o = run_code(&mut s, code, "earlier.jsx");
let e = o.error.expect("reference error");
assert!(e.message.contains("nope"), "{e:?}");
assert_eq!(e.line, Some(6), "{e:?}");
}
#[test]
fn match_names_and_lookups() {
let mut s = session();
@@ -1375,3 +1387,42 @@ fn scripted_levels_alpha_controls_resolve_from_the_effect() {
);
assert_eq!(o.result, json!([0.5, 0.5]));
}
/// After Effects resolves the Transform properties and Source Text from the layer itself, without
/// naming their group: `layer.property("Position")` (#1348).
#[test]
fn layer_property_resolves_transform_and_source_text_shortcuts() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("x", 100, 100, 1, 5, 30);
var t = c.layers.addText("hi");
var s = c.layers.addSolid([1, 0, 0], "s", 100, 100, 1);
var listed = [t.property("Source Text"), s.property("Position"), s.property("Opacity"), s.property("Scale")].join(", ");
s.property("Position").setValue([30, 40]);
s.property("Opacity").setValue(25);
t.property("Source Text").setValue("bye");
s.threeDLayer = true;
[listed, s.property("Position").value[0], s.property("Opacity").value, t.property("Source Text").value.text,
s.property("ADBE Position").matchName, s("Anchor Point").matchName, s.property("Rotation").matchName,
s.property("X Rotation").matchName, s.property("Orientation").matchName]
"#,
);
assert_eq!(
o.result,
json!([
"[object Property], [object Property], [object Property], [object Property]",
30,
25,
"bye",
"ADBE Position",
"ADBE Anchor Point",
"ADBE Rotate Z",
"ADBE Rotate X",
"ADBE Orientation"
])
);
let (_, c) = comp_named(&s, "x");
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
}
+7
View File
@@ -94,3 +94,10 @@ fn calling_without_a_name_is_a_script_error() {
let o = ok(&mut s, "var c = app.project.items.addComp('N', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l.name = 'Ctl'; l");
assert_eq!(o.result, json!({ "type": "[object AVLayer]", "name": "Ctl" }));
}
#[test]
fn app_preferences_save_to_disk_is_callable() {
let mut s = session();
let o = ok(&mut s, "app.preferences.saveToDisk(); app.preferences.reload(); [typeof app.preferences.saveToDisk, app.preferences.getPrefAsLong('a', 'b')]");
assert_eq!(o.result, json!(["function", 0]));
}
+64
View File
@@ -0,0 +1,64 @@
use kurbo::{Affine, BezPath, Rect, Shape as _};
use crate::{Doc, Group, Node};
impl Doc {
/// Geometric artwork bounds in document pixels, including strokes and clipping.
/// Opacity and soft masks do not change the artwork's dimensions.
pub fn artwork_bounds(&self) -> Option<Rect> {
let page = Rect::new(0.0, 0.0, self.width, self.height);
group_bounds(&self.root, Affine::IDENTITY, 0, page)
}
}
fn path_bounds(path: BezPath) -> Option<Rect> {
if path.elements().is_empty() || !path.is_finite() {
return None;
}
let r = path.bounding_box();
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
}
fn group_bounds(group: &Group, transform: Affine, depth: usize, page: Rect) -> Option<Rect> {
if depth > 256 {
return Some(page);
}
let transform = transform * group.transform;
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
return None;
}
let mut bounds: Option<Rect> = None;
for node in &group.children {
let rect = match node {
Node::Group(g) => group_bounds(g, transform, depth + 1, page),
Node::Image(i) => {
let rect = Rect::new(0.0, 0.0, f64::from(i.width), f64::from(i.height));
path_bounds(transform * i.transform * rect.to_path(0.01))
}
Node::Shape(s) => {
let m = transform * s.transform;
let path = s.geom.to_path();
let mut rect = s.fill.as_ref().and_then(|_| path_bounds(m * &path));
if let Some(stroke) = &s.stroke {
let scale = m.determinant().abs().sqrt();
if scale.is_finite()
&& path.is_finite()
&& let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &stroke.style(), scale)
&& let Some(r) = path_bounds(m * outline)
{
rect = Some(rect.map_or(r, |b| b.union(r)));
}
}
rect
}
};
if let Some(r) = rect {
bounds = Some(bounds.map_or(r, |b| b.union(r)));
}
}
let mut bounds = bounds?;
for (clip, _) in &group.clip {
bounds = bounds.intersect(path_bounds(transform * clip)?);
}
(bounds.width() > 0.0 && bounds.height() > 0.0).then_some(bounds)
}
+23
View File
@@ -6,6 +6,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod bounds;
mod color;
mod pathdata;
mod raster;
@@ -108,6 +109,28 @@ pub struct Stroke {
pub dash: Option<(Vec<f64>, f64)>,
}
impl Stroke {
fn style(&self) -> effectcraft_path::StrokeStyle {
effectcraft_path::StrokeStyle {
width: self.width,
cap: match self.cap {
Cap::Butt => effectcraft_path::Cap::Butt,
Cap::Round => effectcraft_path::Cap::Round,
Cap::Square => effectcraft_path::Cap::Square,
},
join: match self.join {
Join::Miter => effectcraft_path::Join::Miter,
Join::Round => effectcraft_path::Join::Round,
Join::Bevel => effectcraft_path::Join::Bevel,
},
miter: self.miter,
dash: self.dash.clone(),
taper: None,
wave: None,
}
}
}
/// Shape geometry in its user space (basic shapes are kept so they can become parametric shape
/// layer items).
#[derive(Clone, Debug, PartialEq)]
+1 -17
View File
@@ -405,23 +405,7 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox);
}
if let Some(st) = &s.stroke {
let style = effectcraft_path::StrokeStyle {
width: st.width,
cap: match st.cap {
crate::Cap::Butt => effectcraft_path::Cap::Butt,
crate::Cap::Round => effectcraft_path::Cap::Round,
crate::Cap::Square => effectcraft_path::Cap::Square,
},
join: match st.join {
crate::Join::Miter => effectcraft_path::Join::Miter,
crate::Join::Round => effectcraft_path::Join::Round,
crate::Join::Bevel => effectcraft_path::Join::Bevel,
},
miter: st.miter,
dash: st.dash.clone(),
taper: None,
wave: None,
};
let style = st.style();
let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h);
paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox);
}
+12 -2
View File
@@ -45,7 +45,13 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
out.close = Some(false);
}
if pressed_outside(ui.ctx(), &area.response) {
// A press in the pass that first draws the picker must not close it: an Area's rect is only
// at its requested position from its second pass on (the first pass reports it at the
// origin), so the click that just opened the picker would be read as a press outside it and
// the picker would flash and close in that one frame (#1476).
let pass_id = id.with("open-pass");
let drawn_before = ui.data(|d| d.get_temp::<u64>(pass_id)).is_some_and(|p| p < ui.ctx().cumulative_pass_nr());
if drawn_before && pressed_outside(ui.ctx(), &area.response) {
out.close.get_or_insert(true);
}
if let Some(keep) = out.close {
@@ -56,13 +62,17 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
ui.data_mut(|d| {
d.insert_temp(id.with("open"), false);
d.remove::<[f32; 3]>(orig_id);
d.remove::<u64>(pass_id);
// Closed: the next Escape is the dialog's again.
d.remove::<u64>(open_pass_id());
});
} else {
// Still open after this pass: dialogs leave Escape to the picker (see `open_recently`).
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| d.insert_temp(open_pass_id(), pass));
ui.data_mut(|d| {
d.insert_temp(pass_id, pass);
d.insert_temp(open_pass_id(), pass);
});
}
out.changed
}
+70 -10
View File
@@ -111,6 +111,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) ->
_ => {}
}
}
if language == "de" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") {
match source {
"Reveal in Explorer" => return "Im Explorer anzeigen",
"Reveal in File Manager" => return "Im Dateimanager anzeigen",
_ => {}
}
}
if language == "cs" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") {
match source {
"Reveal in Explorer" => return "Zobrazit v Průzkumníku",
@@ -2034,9 +2041,9 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("view.zoomOut", "Zoom Out", "ズームアウト"),
("", "Resolution", "解像度"),
("view.res.full", "Full", "フル画質"),
("view.res.half", "Half", "1/2画質"),
("view.res.third", "Third", "1/3画質"),
("view.res.quarter", "Quarter", "1/4画質"),
("view.res.half", "Half", "1/2 画質"),
("view.res.third", "Third", "1/3 画質"),
("view.res.quarter", "Quarter", "1/4 画質"),
("view.res.custom", "Custom...", "カスタム..."),
("view.displayColorManagement", "Use Display Color Management", "ディスプレイカラーマネジメントを使用"),
("", "Simulate Output", "出力をシミュレート"),
@@ -5555,7 +5562,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("layer.settings", "Layer Settings...", "Ebeneneinstellungen..."),
("layer.openLayer", "Open Layer", "Ebene öffnen"),
("layer.openSource", "Open Layer Source", "Ebenenquelle öffnen"),
("layer.revealInFinder", "Reveal in Finder", "Im Finder einblenden"),
("layer.revealInFinder", "Reveal in Finder", "Im Finder anzeigen"),
("", "Mask", "Maske"),
("layer.addMask", "New Mask", "Neue Maske"),
("layer.mask.shape", "Mask Shape...", "Maskenform..."),
@@ -5565,7 +5572,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("layer.mask.reset", "Reset Mask", "Maske zurücksetzen"),
("layer.mask.remove", "Remove Mask", "Maske entfernen"),
("layer.mask.removeAll", "Remove All Masks", "Alle Masken entfernen"),
("track.mask", "Track Mask", "Maske tracken"),
("track.mask", "Track Mask", "Maske verfolgen"),
("", "Mode", "Modus"),
("layer.mask.mode", "None", "Ohne"),
("layer.mask.mode", "Add", "Addieren"),
@@ -5602,8 +5609,8 @@ const GERMAN: &[(&str, &str, &str)] = &[
("layer.hideOtherVideo", "Hide Other Video", "Anderes Video ausblenden"),
("layer.showAllVideo", "Show All Video", "Gesamtes Video einblenden"),
("layer.unlockAll", "Unlock All Layers", "Alle Ebenen entsperren"),
("layer.expressions", "Enable Expressions", "Expressions aktivieren"),
("layer.expressions", "Disable Expressions", "Expressions deaktivieren"),
("layer.expressions", "Enable Expressions", "Ausdrücke aktivieren"),
("layer.expressions", "Disable Expressions", "Ausdrücke deaktivieren"),
("layer.setSwitch", "Shy", "Schüchtern"),
("layer.setSwitch", "Lock", "Sperren"),
("layer.setSwitch", "Audio", "Audio"),
@@ -5631,7 +5638,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("layer.autoOrient", "Auto-Orient...", "Automatische Ausrichtung..."),
("", "Time", "Zeit"),
("layer.enableTimeRemap", "Enable Time Remapping", "Zeitverzerrung aktivieren"),
("layer.timeReverse", "Time-Reverse Layer", "Ebenenreihenfolge umkehren"),
("layer.timeReverse", "Time-Reverse Layer", "Ebene zeitlich umkehren"),
("layer.timeStretch", "Time Stretch...", "Zeitdehnung..."),
("layer.freezeFrame", "Freeze Frame", "Frame fixieren"),
("layer.freezeOnLastFrame", "Freeze On Last Frame", "Bei letztem Frame fixieren"),
@@ -5700,7 +5707,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("layer.trackMatte", "Matte with Layer Below", "Spurmaske mit Ebene darunter"),
("", "Layer Styles", "Ebenenstile"),
("layer.style.options", "Layer Style Options...", "Ebenenstiloptionen..."),
("layer.style.convertToEditable", "Convert to Editable Styles", "In editierbare Formate umwandeln"),
("layer.style.convertToEditable", "Convert to Editable Styles", "In bearbeitbare Stile umwandeln"),
("layer.style.showAll", "Show All", "Alle anzeigen"),
("layer.style.removeAll", "Remove All", "Alle entfernen"),
("layer.style.dropShadow", "Drop Shadow", "Schlagschatten"),
@@ -5715,7 +5722,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("path.groupShapes", "Group Shapes", "Formen gruppieren"),
("path.ungroupShapes", "Ungroup Shapes", "Gruppierung von Formen aufheben"),
("", "Arrange", "Anordnen"),
("layer.arrange", "Bring Layer to Front", "Ebene ganz nach vorn"),
("layer.arrange", "Bring Layer to Front", "Ebene ganz nach vorne"),
("layer.arrange", "Bring Layer Forward", "Ebene nach vorne"),
("layer.arrange", "Send Layer Backward", "Ebene nach hinten"),
("layer.arrange", "Send Layer to Back", "Ebene ganz nach hinten"),
@@ -6679,6 +6686,59 @@ mod tests {
assert_eq!(entry(&app, &redo, "Redo Text Čeština".into()), "Znovu Text Čeština");
}
#[test]
fn german_reveal_labels_follow_the_platform_file_manager() {
// As `czech_…` and `ukrainian_…` do. Asserted through `label`, not `entry`: `entry`
// returns the English for German, Russian and French, which have catalogues but are
// not wired into the menus (see the undo/redo match in `entry` and `supported`).
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.session.execute("prefs.set", json!({"key": "general.language", "value": "de"})).unwrap();
for command in ["file.revealInFinder", "layer.revealInFinder"] {
for (source, translated) in [
("Reveal in Finder", "Im Finder anzeigen"),
("Reveal in Explorer", "Im Explorer anzeigen"),
("Reveal in File Manager", "Im Dateimanager anzeigen"),
] {
assert_eq!(label(&app, command, source), translated, "{command} / {source}");
// Another command keeps a label that merely looks like one of these.
assert_eq!(label(&app, "file.openRecent", source), source);
}
assert_eq!(label(&app, command, "My file.mp4"), "My file.mp4", "a user's filename is never looked up");
}
}
#[test]
fn german_menu_labels_name_what_their_command_does() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.session.execute("prefs.set", json!({"key": "general.language", "value": "de"})).unwrap();
// "Ebenenreihenfolge umkehren" reads as reversing the stacking order, not time, and
// "Formate" are formats, not layer styles (#1443).
assert_eq!(label(&app, "layer.timeReverse", "Time-Reverse Layer"), "Ebene zeitlich umkehren");
assert_eq!(label(&app, "keys.timeReverse", "Time-Reverse Keyframes"), "Keyframes zeitlich umkehren");
assert_eq!(label(&app, "layer.style.convertToEditable", "Convert to Editable Styles"), "In bearbeitbare Stile umwandeln");
}
#[test]
fn german_uses_one_word_per_term() {
let german: Vec<&str> = GERMAN.iter().map(|(_, _, de)| *de).collect();
let with = |needle: &str| german.iter().filter(|de| de.contains(needle)).copied().collect::<Vec<_>>();
// "Expression" is "Ausdruck", not the English word.
assert!(with("Expression").is_empty(), "{:?}", with("Expression"));
// "Track" as a verb is "verfolgen", not "tracken".
assert!(with("tracken").is_empty(), "{:?}", with("tracken"));
// "Reveal <somewhere>" is "anzeigen". ("einblenden" stays right for "Show All",
// "Fade from Blur" and the like, so only the Reveal entries are checked.)
let reveal: Vec<&str> =
GERMAN.iter().filter(|(_, en, _)| en.starts_with("Reveal")).map(|(_, _, de)| *de).filter(|de| !de.to_lowercase().contains("anzeigen")).collect();
assert!(reveal.is_empty(), "{reveal:?}");
// One spelling of "to the front".
let vorn: Vec<&str> = german.iter().filter(|de| de.ends_with("nach vorn")).copied().collect();
assert!(vorn.is_empty(), "{vorn:?}");
// The terms those entries joined are still the majority ones.
assert!(with("verfolgen").len() >= 4, "{:?}", with("verfolgen"));
assert!(with("anzeigen").len() >= 12, "{}", with("anzeigen").len());
}
#[test]
fn czech_reveal_labels_follow_the_platform_file_manager() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
+11 -4
View File
@@ -228,6 +228,7 @@ const JAPANESE: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"),
("Effects & Presets options", "エフェクト&プリセットのオプション"),
("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"),
("Copy Source Path", "ソースパスをコピー"),
("Effects: {}", "エフェクト:{}"),
("Empty folder", "空のフォルダー"),
("Enable Depth of Field", "被写界深度を有効化"),
@@ -553,7 +554,7 @@ const JAPANESE: &[(&str, &str)] = &[
("3D Reference Axes", "3D参照軸"),
("Affect all layers that use this solid", "この平面を使用するすべてのレイヤーに適用"),
("Choose grid and guide options", "グリッドとガイドのオプションを選択"),
("Colorize", "カラー化"),
("Colorize", "色付き"),
("Custom", "カスタム"),
("Custom...", "カスタム..."),
("Fit", "フィット"),
@@ -561,7 +562,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Full", "フル画質"),
("Half", "1/2 画質"),
("Only this layer uses this solid.", "この平面を使用するのはこのレイヤーだけです。"),
("Proportional Grid", "均等グリッド"),
("Proportional Grid", "プロポーショナルグリッド"),
("Quarter", "1/4 画質"),
("Region of Interest", "関心領域"),
("Reset Exposure", "露出をリセット"),
@@ -587,7 +588,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Import Image Sequence", "画像シーケンスを読み込み"),
("Import PDF / Illustrator File", "PDF / Illustratorファイルを読み込み"),
("Import Photoshop File", "Photoshopファイルを読み込み"),
("Interpret Unlabeled Alpha As", "ラベルなしアルファの解釈"),
("Interpret Unlabeled Alpha As", "ラベルのないアルファを変換"),
("Join the ArtCraft Discord", "ArtCraftのDiscordに参加"),
("Mercury GPU Acceleration ({})", "Mercury GPUアクセラレーション({})"),
("Mercury Software Only", "Mercuryソフトウェアのみ"),
@@ -595,7 +596,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Off (Final Quality)", "オフ(最終品質)"),
("OpenGL (compatibility)", "OpenGL(互換)"),
("Opus Bitrate: {} kbps", "Opusビットレート:{} kbps"),
("RGB Straight", "RGB(ストレート)"),
("RGB Straight", "RGB ストレート"),
("Red", "赤"),
("Tone map", "トーンマップ"),
("Use In-Window Menu Bar on macOS", "macOSでウィンドウ内メニューバーを使用"),
@@ -772,6 +773,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"),
("Effects & Presets options", "效果和预设选项"),
("Copy Presets Folder Path", "复制预设文件夹路径"),
("Copy Source Path", "复制源路径"),
("Effects: {}", "效果:{}"),
("Empty folder", "空文件夹"),
("Enable Depth of Field", "启用景深"),
@@ -1504,6 +1506,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"),
("Effects & Presets options", "效果和預設集選項"),
("Copy Presets Folder Path", "複製預設集資料夾路徑"),
("Copy Source Path", "複製來源路徑"),
("Effects: {}", "效果:{}"),
("Empty folder", "空資料夾"),
("Enable Depth of Field", "啟用景深"),
@@ -2239,6 +2242,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."),
("Effects & Presets options", "Параметри ефектів і наборів"),
("Copy Presets Folder Path", "Копіювати шлях до папки наборів"),
("Copy Source Path", "Копіювати шлях до джерела"),
("Effects: {}", "Ефекти: {}"),
("Empty folder", "Порожня тека"),
("Enable Depth of Field", "Увімкнути глибину різкості"),
@@ -2791,6 +2795,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."),
("Effects & Presets options", "Opções de Efeitos e predefinições"),
("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"),
("Copy Source Path", "Copiar caminho da origem"),
("Effects: {}", "Efeitos: {}"),
("Empty folder", "Pasta vazia"),
("Enable Depth of Field", "Ativar profundidade de campo"),
@@ -3241,6 +3246,7 @@ const CZECH: &[(&str, &str)] = &[
("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."),
("Effects & Presets options", "Možnosti efektů a přednastavení"),
("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"),
("Copy Source Path", "Kopírovat cestu ke zdroji"),
("Effects: {}", "Efekty: {}"),
("Empty folder", "Prázdná složka"),
("Enable Depth of Field", "Povolit hloubku ostrosti"),
@@ -3653,6 +3659,7 @@ const CZECH: &[(&str, &str)] = &[
/// order. Terms follow the Spanish menu catalog (composición, material de archivo, cola de procesamiento,
/// ajuste preestablecido, método abreviado). Written from the English source.
const SPANISH: &[(&str, &str)] = &[
("Copy Source Path", "Copiar ruta de origen"),
("Appearance Settings…", "Ajustes de apariencia…"),
("Add to favorites", "Añadir a favoritos"),
("Remove from favorites", "Quitar de favoritos"),
+6 -1
View File
@@ -291,7 +291,7 @@ fn only_comp(p: &Value) -> Option<effectcraft_engine::project::ItemId> {
/// Execute a UI or engine command by id.
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
if id == "effects.copyPresetsFolderPath" {
if matches!(id, "effects.copyPresetsFolderPath" | "mediaBrowser.copySourcePath") {
return frontend(app, ctx, id, params);
}
// Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of
@@ -880,6 +880,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
let now = ctx.input(|i| i.time);
let v = &mut app.ui.viewer;
Ok(match id {
"mediaBrowser.copySourcePath" => {
let path = p.get("path").and_then(Value::as_str).filter(|path| !path.is_empty()).ok_or("A source path is required")?;
ctx.copy_text(path.to_string());
json!({"path": path})
}
"effects.copyPresetsFolderPath" => {
if cfg!(target_arch = "wasm32") {
return Err("The web app has no local presets folder".into());
+79 -19
View File
@@ -8,6 +8,7 @@
use crate::i18n::tr;
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use std::sync::Arc;
use crate::icons::{self, Icon};
use crate::panels::DragPayload;
@@ -228,7 +229,7 @@ fn preset_row(
name: &str,
auto_id: &str,
query: &str,
on_apply: Apply,
on_apply: impl FnOnce() -> Apply,
apply: &mut Option<Apply>,
) {
let t = app.tokens;
@@ -245,22 +246,22 @@ fn preset_row(
highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t);
app.auto.add(auto_id, r, name);
if resp.double_clicked() {
*apply = Some(on_apply);
*apply = Some(on_apply());
}
}
/// User preset files, rescanned every few seconds (and on Refresh List).
fn user_presets(ctx: &egui::Context, force: bool) -> Vec<PresetFile> {
fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<PresetFile>) -> Arc<Vec<PresetFile>> {
let id = egui::Id::new("fx-user-presets");
let now = ctx.input(|i| i.time);
if !force
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(id))
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(id))
&& now - at < 5.0
{
return (*v).clone();
return v;
}
let v = presets_dir().map(|d| scan_presets(&d)).unwrap_or_default();
ctx.data_mut(|d| d.insert_temp(id, (now, std::sync::Arc::new(v.clone()))));
let v = Arc::new(scan());
ctx.data_mut(|d| d.insert_temp(id, (now, v.clone())));
v
}
@@ -288,6 +289,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add("effects.menu", mr, "Effects & Presets options");
let view = app.ui.effects_view.clone();
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
let copy_label = format!(" {}", tr("Copy Presets Folder Path"));
let menu = vec![
mark(view.depth == "all", "Show Effects for All Color Depths"),
mark(view.depth == "32", "Show 32 bpc Effects Only"),
@@ -301,7 +303,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
" Refresh List".into(),
" Clear Recently Used".into(),
"-".into(),
format!(" {}", tr("Copy Presets Folder Path")),
copy_label.clone(),
];
let mut force_scan = false;
let separators = menu.iter().filter(|label| *label == "-").count();
@@ -314,7 +316,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.separator();
} else {
let resp = ui.selectable_label(false, label);
if i == 12 {
if label == &copy_label {
app.auto.add("effects.copyPresetsFolderPath", resp.rect, "Copy Presets Folder Path");
}
if resp.clicked() {
@@ -330,6 +332,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some(i) = choice {
let v = &mut app.ui.effects_view;
match i {
i if menu.get(i) == Some(&copy_label) => {
if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
app.ui.status = e;
}
}
0 => v.depth = "all".into(),
1 => v.depth = "32".into(),
2 => v.depth = "gpu".into(),
@@ -338,11 +345,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
7 => v.alphabetical = true,
9 => force_scan = true,
10 => app.ui.effects_recent.clear(),
12 => {
if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
app.ui.status = e;
}
}
_ => {}
}
}
@@ -389,13 +391,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
categories.push(c);
}
}
let files: Vec<PresetFile> =
user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
let user = user_presets(&ctx, force_scan, || presets_dir().map(|d| scan_presets(&d)).unwrap_or_default());
let files: Vec<&PresetFile> = user.iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty();
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
for (id, name) in text {
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply);
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, || Apply::TextPreset(id.clone()), &mut apply);
}
}
for cat in &categories {
@@ -403,7 +405,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
continue;
}
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply);
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, || Apply::Builtin(id.clone()), &mut apply);
}
}
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
@@ -417,7 +419,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&f.name,
&format!("effects.preset.file.{}", f.name),
&query,
Apply::PresetFile(f.path.clone()),
|| Apply::PresetFile(f.path.clone()),
&mut apply,
);
}
@@ -536,6 +538,64 @@ mod tests {
let v = scan_presets(&d);
assert_eq!(v.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), vec!["bounce", "Zoom In"]);
assert!(scan_presets(&d.join("missing")).is_empty());
let ctx = egui::Context::default();
let set_time = |time| {
let mut output = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |_| {});
output.textures_delta.clear();
};
let scans = std::cell::Cell::new(0);
let scan = || {
scans.set(scans.get() + 1);
scan_presets(&d)
};
set_time(1.0);
let first = user_presets(&ctx, false, scan);
assert_eq!(first.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
std::fs::remove_file(d.join("bounce.ECPRESET")).unwrap();
std::fs::write(d.join("Another.ecpreset"), "{}").unwrap();
set_time(5.999);
let hit = user_presets(&ctx, false, scan);
assert!(Arc::ptr_eq(&first, &hit));
assert_eq!(hit.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
assert_eq!(scans.get(), 1);
set_time(6.0);
let expired = user_presets(&ctx, false, scan);
assert_eq!(expired.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Another", "Zoom In"]);
std::fs::remove_file(d.join("Another.ecpreset")).unwrap();
let refreshed = user_presets(&ctx, true, scan);
assert_eq!(refreshed.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Zoom In"]);
assert_eq!(scans.get(), 3);
assert_eq!(first.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
#[ignore = "warm custom-preset cache benchmark"]
fn user_preset_cache_benchmark() {
let ctx = egui::Context::default();
let files = user_presets(&ctx, false, || {
(0..1000).map(|i| PresetFile { name: format!("Preset {i:04}"), path: format!("/presets/Preset {i:04}.ecpreset") }).collect()
});
assert_eq!(files.first().unwrap().name, "Preset 0000");
assert_eq!(files.last().unwrap().name, "Preset 0999");
let mut before = Vec::new();
let mut after = Vec::new();
for _ in 0..5 {
let start = std::time::Instant::now();
for _ in 0..1000 {
let (_, shared) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
std::hint::black_box((*shared).clone());
}
before.push(start.elapsed());
let start = std::time::Instant::now();
for _ in 0..1000 {
std::hint::black_box(user_presets(&ctx, false, Vec::new));
}
after.push(start.elapsed());
}
assert!(Arc::ptr_eq(&files, &user_presets(&ctx, false, Vec::new)));
before.sort();
after.sort();
println!("1,000 custom presets, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
}
}
+10 -1
View File
@@ -228,7 +228,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
"file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => (
"Import PDF / Illustrator File".into(),
vec![
Field::choice("importAs", "Import Kind", &[("Footage", json!("footage")), ("Composition", json!("composition"))], 0),
Field::choice(
"importAs",
"Import Kind",
&[
("Footage", json!("footage")),
("Composition", json!("composition")),
("Composition - Retain Layer Sizes", json!("compositionLayerSizes")),
],
0,
),
Field::num("page", "Page", 1.0),
],
),
+63 -4
View File
@@ -15,6 +15,7 @@ use effectcraft_engine::media_browser as mb;
use effectcraft_engine::project::ItemId;
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::{Value, json};
use std::sync::Arc;
use super::DragPayload;
use super::panel_kit as kit;
@@ -24,6 +25,7 @@ use crate::{EffectcraftApp, widgets};
// ---------------------------------------------------------------- Media Browser
type Thumb = Option<(egui::TextureHandle, [u32; 2])>;
type Listing = (f64, Arc<Result<Vec<mb::Entry>, String>>);
/// Thumbnail of a file (cached per path; `None` while not decoded or not decodable).
fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb {
@@ -76,11 +78,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Listing cached per folder for a second.
let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only));
let now = ctx.input(|i| i.time);
let listing: Option<(f64, Result<Vec<mb::Entry>, String>)> = ctx.data(|d| d.get_temp(lid));
let listing: Option<Listing> = ctx.data(|d| d.get_temp(lid));
let entries = match listing {
Some((at, l)) if now - at < 1.0 => l,
_ => {
let l = b.list(&dir, st.importable_only);
let l = Arc::new(b.list(&dir, st.importable_only));
ctx.data_mut(|d| d.insert_temp(lid, (now, l.clone())));
l
}
@@ -115,7 +117,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
if kit::button(app, ui, Rect::from_min_size(pos2(ax, y), vec2(w, 20.0)), &format!("mediaBrowser.action.{id}"), label, false) {
kit::exec(app, "mediaBrowser.action", json!({"action": id}));
ctx.data_mut(|d| d.remove::<(f64, Result<Vec<mb::Entry>, String>)>(lid));
ctx.data_mut(|d| d.remove::<Listing>(lid));
}
ax -= 6.0;
}
@@ -150,7 +152,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Files.
let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max);
let entries = match entries {
let entries = match entries.as_ref() {
Ok(e) => e,
Err(e) => {
p.text(list.center(), Align2::CENTER_CENTER, e, Tokens::ui(12.0), t.danger);
@@ -209,6 +211,14 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
egui::DragAndDrop::set_payload(&ctx, DragPayload::Files(vec![e.path.clone()]));
}
crate::widgets::context_menu(&resp, |ui| {
let copy = ui.button(tr("Copy Source Path"));
app.auto.add(&format!("mediaBrowser.entry.{}.copySourcePath", e.name), copy.rect, "Copy Source Path");
if copy.clicked() {
if let Err(e) = crate::menus::invoke(app, &ctx, "mediaBrowser.copySourcePath", json!({"path": e.path})) {
app.ui.status = e;
}
ui.close();
}
if !e.is_dir && ui.button(tr("Import")).clicked() {
actions.push(("mediaBrowser.import", json!({"paths": [e.path]})));
ui.close();
@@ -409,3 +419,52 @@ pub fn import_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect) -> f
}
full.max.y - 16.0 - bottom
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "Media Browser warm listing cache benchmark"]
fn listing_cache_benchmark() {
let ctx = egui::Context::default();
let entries: Vec<_> = (0..1000)
.map(|i| mb::Entry {
name: format!("Clip {i:04}.mov"),
path: format!("/Shoot/作品 Clip {i:04}.mov"),
is_dir: false,
size: 1,
modified: None,
kind: Some("video"),
})
.collect();
let old_id = egui::Id::new("old-list");
let new_id = egui::Id::new("new-list");
ctx.data_mut(|d| {
d.insert_temp(old_id, (0.0, Ok::<_, String>(entries.clone())));
d.insert_temp(new_id, (0.0, Arc::new(Ok::<_, String>(entries))));
});
let (_, original) = ctx.data(|d| d.get_temp::<Listing>(new_id)).unwrap();
assert_eq!(original.as_ref().as_ref().unwrap()[0].name, "Clip 0000.mov");
let mut before = Vec::new();
let mut after = Vec::new();
for _ in 0..5 {
let start = std::time::Instant::now();
for _ in 0..1000 {
let _ = std::hint::black_box(ctx.data(|d| d.get_temp::<(f64, Result<Vec<mb::Entry>, String>)>(old_id)).unwrap());
}
before.push(start.elapsed());
let start = std::time::Instant::now();
for _ in 0..1000 {
std::hint::black_box(ctx.data(|d| d.get_temp::<Listing>(new_id)).unwrap());
}
after.push(start.elapsed());
}
let (_, cached) = ctx.data(|d| d.get_temp::<Listing>(new_id)).unwrap();
assert!(Arc::ptr_eq(&original, &cached));
assert_eq!(cached.as_ref().as_ref().unwrap().last().unwrap().name, "Clip 0999.mov");
before.sort();
after.sort();
println!("1,000 Media Browser entries, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
}
}
+143 -1
View File
@@ -84,11 +84,14 @@ pub const COLUMNS: [(&str, &str, f32); 6] = [
/// Sort siblings by the Project panel's sort column; ties fall back to the name.
pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
if col == "size" {
sort_by_size(items, desc, file_size);
return;
}
let name = |i: &Item| i.name.to_lowercase();
items.sort_by(|a, b| {
let o = match col {
"type" => a.type_name().cmp(b.type_name()),
"size" => file_size(a).cmp(&file_size(b)),
"fps" => a.frame_rate().map(|r| r.as_f64()).partial_cmp(&b.frame_rate().map(|r| r.as_f64())).unwrap_or(std::cmp::Ordering::Equal),
"duration" => a.duration().cmp(&b.duration()),
"path" => file_path(a).cmp(file_path(b)),
@@ -101,6 +104,15 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
});
}
fn sort_by_size(items: &mut [&Item], desc: bool, mut size_of: impl FnMut(&Item) -> Option<u64>) {
let mut key = |it: &&Item| (size_of(it), it.name.to_lowercase());
if desc {
items.sort_by_cached_key(|it| std::cmp::Reverse(key(it)));
} else {
items.sort_by_cached_key(key);
}
}
/// Where a drop at a row lands: into a folder row, else into the row's folder; `None` row = root.
pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option<ItemId>) -> Option<ItemId> {
let it = project.item(row?)?;
@@ -1113,6 +1125,136 @@ pub fn set_column(cols: &mut Vec<String>, key: &str, on: bool) {
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::project::{Footage, Project};
struct SizeFiles(std::path::PathBuf);
impl SizeFiles {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("effectcraft-size-{}-{n}", std::process::id()));
std::fs::create_dir(&path).unwrap();
Self(path)
}
fn add(&self, project: &mut Project, name: &str, bytes: Option<u64>) {
let path = self.0.join(name);
if let Some(bytes) = bytes {
std::fs::File::create(&path).unwrap().set_len(bytes).unwrap();
}
project.add_item(name, Label::Aqua, None, ItemKind::Footage(Footage { path: path.to_string_lossy().into_owned(), ..Default::default() }));
}
}
impl Drop for SizeFiles {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn size_keys_are_sampled_once_with_stable_ties() {
let mut project = Project::default();
for name in ["Zeta", "alpha", "ALPHA", "Missing", "Zero"] {
project.add_item(name, Label::Aqua, None, ItemKind::Folder);
}
for (desc, expected) in [(false, ["Missing", "Zero", "alpha", "ALPHA", "Zeta"]), (true, ["Zeta", "alpha", "ALPHA", "Zero", "Missing"])] {
let mut reads = std::collections::BTreeMap::new();
let mut rows = project.children(None);
sort_by_size(&mut rows, desc, |item| {
*reads.entry(item.id).or_insert(0) += 1;
match item.name.as_str() {
"Zeta" => Some(30),
"alpha" | "ALPHA" => Some(10),
"Zero" => Some(0),
_ => None,
}
});
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), expected);
assert_eq!(reads.len(), 5);
assert!(reads.values().all(|&count| count == 1));
}
}
#[test]
fn size_sort_reads_files_again_after_they_change() {
let files = SizeFiles::new();
let mut project = Project::default();
project.add_item("Folder", Label::Aqua, None, ItemKind::Folder);
files.add(&mut project, "Missing", None);
files.add(&mut project, "Zero", Some(0));
files.add(&mut project, "Small", Some(10));
files.add(&mut project, "Large", Some(20));
let mut rows = project.children(None);
sort_items(&mut rows, "size", false);
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Missing", "Zero", "Small", "Large"]);
sort_items(&mut rows, "size", true);
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Large", "Small", "Zero", "Missing", "Folder"]);
std::fs::OpenOptions::new().write(true).open(files.0.join("Small")).unwrap().set_len(30).unwrap();
sort_items(&mut rows, "size", false);
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Missing", "Zero", "Large", "Small"]);
std::fs::remove_file(files.0.join("Large")).unwrap();
sort_items(&mut rows, "size", false);
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Large", "Missing", "Zero", "Small"]);
}
#[test]
fn size_sort_accepts_empty_and_singleton_lists() {
let mut project = Project::default();
let id = project.add_item("Only", Label::Aqua, None, ItemKind::Folder);
for desc in [false, true] {
sort_items(&mut [], "size", desc);
let mut rows = project.children(None);
sort_items(&mut rows, "size", desc);
assert_eq!(rows.iter().map(|item| item.id).collect::<Vec<_>>(), [id]);
}
}
#[test]
#[ignore = "filesystem benchmark; run with --ignored --nocapture"]
fn size_sort_benchmark() {
let files = SizeFiles::new();
let mut project = Project::default();
for i in 0..1000 {
files.add(&mut project, &format!("Clip {i:04}"), Some((i * 137) % 1000 + 1));
}
let original = project.children(None);
for item in &original {
assert!(file_size(item).is_some());
}
let mut before_times = Vec::new();
let mut after_times = Vec::new();
let mut before_reads = 0;
let mut after_reads = 0;
for _ in 0..5 {
let mut before = original.clone();
before_reads = 0;
let start = std::time::Instant::now();
before.sort_by(|a, b| {
before_reads += 2;
file_size(a).cmp(&file_size(b)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
before_times.push(start.elapsed());
let mut after = original.clone();
after_reads = 0;
let start = std::time::Instant::now();
sort_by_size(&mut after, false, |item| {
after_reads += 1;
file_size(item)
});
after_times.push(start.elapsed());
assert_eq!(before.iter().map(|item| item.id).collect::<Vec<_>>(), after.iter().map(|item| item.id).collect::<Vec<_>>());
assert_eq!(after_reads, 1000);
assert!(before_reads > after_reads);
}
before_times.sort();
after_times.sort();
println!(
"1,000 footage files; warmed metadata; five paired sorts\nmetadata reads/sort: {before_reads} -> {after_reads}\nmedian sort time: {:?} -> {:?}",
before_times[2], after_times[2]
);
}
#[test]
fn ae_duration_and_size_formats() {
+11
View File
@@ -244,6 +244,17 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
}
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
let vx = r.min.x + (r.width() * 0.5).max(130.0);
let label = Rect::from_min_max(pos2(r.min.x + 42.0, r.min.y), pos2(vx - 20.0, r.max.y));
let resp = ui.interact(label, egui::Id::new(("props-label", uid)), Sense::click());
app.auto.add(&format!("{base}.label"), label, &pr.name);
if resp.double_clicked() {
app.ui.timeline.search.clear();
app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Property(uid));
if let Err(e) = crate::menus::frontend(app, ui.ctx(), "timeline.revealProps", json!({"props": [{"layer": lid, "prop": uid}]})) {
app.ui.status = e;
}
app.show_panel(PanelKind::Timeline);
}
let merge = format!("props-{uid}");
// With several layers selected the edit applies to all: a scrub by the same change, a typed
// value as it is.
+2
View File
@@ -664,6 +664,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.vertical(|ui| {
ui.set_width(180.0);
egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| {
// The rows touch, so the pointer never falls between two of them.
ui.spacing_mut().item_spacing.y = 0.0;
for p in &all {
let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click());
let sel = page == p.id;
+4
View File
@@ -1895,6 +1895,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Layer) && Some(r.layer) == top) {
app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
}
} else if let Some(crate::state::ScrollTo::Property(uid)) = scroll_to
&& let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Prop { uid: p } if p == uid))
{
app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
}
if ui.rect_contains_pointer(rows_rect) {
let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt));
+10 -2
View File
@@ -1281,6 +1281,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
context_actions.push(("comp.copyFrame".to_owned(), serde_json::json!({"comp": cid.0})));
ui.close();
}
// The comp this viewer shows, which is not always the active one.
let settings = ui.button(crate::i18n::tr("Composition Settings…"));
app.auto.add("viewer.context.settings", settings.rect, "Composition Settings…");
if settings.clicked() {
context_actions.push(("comp.settings".to_owned(), serde_json::json!({"comp": cid.0})));
ui.close();
}
});
for (id, params) in context_actions {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
@@ -2283,9 +2290,10 @@ fn pen_press(
}
/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order.
/// Solo scope matches the renderer: only active AV layers activate it, even when hidden or locked.
/// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After
/// Effects), even when hidden, locked or outside its in-out range.
pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator<Item = &Layer> {
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time));
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
comp.layers
.iter()
.filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo))
+4 -2
View File
@@ -335,13 +335,15 @@ impl TimelineState {
}
}
/// Where a Timeline shortcut scrolls to.
/// Where the Timeline scrolls to.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ScrollTo {
/// D: the current time, centred in the time graph.
Time,
/// X: the topmost selected layer, at the top of the layers.
Layer,
/// A revealed property, at the top of the rows.
Property(u64),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -419,7 +421,7 @@ pub struct TimelineState {
/// The name column shows Source Name instead of Layer Name (click its header).
#[serde(default)]
pub source_name: bool,
/// A scroll asked for by a shortcut (D, X), done when the Timeline is next drawn.
/// A pending scroll, applied when the Timeline is next drawn.
#[serde(skip)]
pub scroll_to: Option<ScrollTo>,
}
@@ -87,6 +87,20 @@ fn timeline_blank_menu_opens_solid_settings() {
assert_eq!(h.state().dialog, Some(Dialog::SolidSettings));
}
/// Right-clicking the composition in the viewer offers Composition Settings…, which opens on the
/// comp the viewer shows.
#[test]
fn viewer_menu_opens_composition_settings() {
let mut h = harness();
let comp = h.state().session.active_comp_id().unwrap();
let p = rect(&h, "viewer.comp").center();
right_click(&mut h, p);
h.get_by_label("Composition Settings…").click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(Dialog::CompSettings));
assert_eq!(h.state().session.active_comp_id(), Some(comp));
}
#[test]
fn project_item_and_timeline_layer_keep_their_own_context_menus() {
let mut h = harness();
+26
View File
@@ -114,6 +114,32 @@ fn is_open(h: &Harness<'_, [f32; 3]>) -> bool {
h.query_by_role_and_label(Role::TextInput, "#").is_some()
}
/// #1476: the click that opens the picker must not close it. The picker's area only reports its
/// real position from its second pass — on the pass it first draws, its rect is at the origin —
/// so a click on a swatch outside that stale rect was read as a press outside the popup and the
/// picker flashed and closed in the frame it opened.
#[test]
fn a_click_that_opens_the_picker_does_not_close_it_in_the_same_frame() {
let mut h = Harness::builder().with_size(vec2(560.0, 400.0)).build_ui_state(
|ui, c: &mut [f32; 3]| {
// Away from the top-left corner: the stale first-pass rect covers it.
ui.allocate_space(vec2(440.0, 120.0));
ui.horizontal(|ui| {
ui.add_space(440.0);
widgets::srgb_color_button(ui, c);
});
},
[1.0, 0.0, 0.0],
);
h.run_steps(2);
let button = h.get_by_role(Role::ColorWell).rect().center();
h.input_mut().events.push(Event::PointerMoved(button));
h.input_mut().events.push(Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(is_open(&h), "the click that opens the picker must not close it");
}
#[test]
fn the_dialog_colour_button_stores_srgb() {
let mut h = picker([1.0, 1.0, 1.0]);
+25
View File
@@ -584,3 +584,28 @@ fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() {
assert!(h.state().auto.find("form.field.missingFrames").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn illustrator_import_offers_and_applies_retain_layer_sizes() {
let path = std::env::temp_dir().join(format!("effectcraft-retain-ui-{}.ai", std::process::id()));
std::fs::write(&path, b"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Contents 4 0 R >> endobj\n4 0 obj << >> stream\n1 0 0 rg 20 15 30 20 re f\nendstream endobj\n%%EOF").unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(effectcraft_host::session()));
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({"paths": [path]})).unwrap();
h.run_steps(3);
assert!(h.query_by_label("Import PDF / Illustrator File").is_some());
click_id(&mut h, "form.field.importAs");
h.get_by_label("Composition - Retain Layer Sizes").click();
h.run_steps(2);
click_id(&mut h, "form.ok");
step_until(&mut h, |h| h.state().session.active_comp().is_some());
let s = &h.state().session;
let comp = s.active_comp().unwrap();
assert_eq!((comp.width, comp.height), (120, 80));
assert_eq!(comp.layers.len(), 1);
let effectcraft_engine::project::LayerSource::Footage { item } = comp.layers[0].source else { panic!("footage") };
let effectcraft_engine::project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") };
assert_eq!((f.width, f.height), (30, 20));
assert!(f.layer.as_ref().unwrap().layer_size);
}
+96
View File
@@ -126,6 +126,102 @@ fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: egui::Mod
h.run_steps(2);
}
#[test]
fn copy_and_paste_key_subsets_across_position_rotation_and_scale() {
for graph in [false, true] {
let (mut h, id, _) = harness();
let paths = ["transform/position", "transform/rotation", "transform/scale"];
for (path, times, values) in [
(paths[0], [0.5, 1.0], [json!([100, 200, 0]), json!([300, 400, 0])]),
(paths[1], [0.5, 1.5], [json!(500), json!(900)]),
(paths[2], [0.0, 1.0], [json!([1500, 1600, 100]), json!([1700, 1800, 100])]),
] {
for (time, value) in times.into_iter().zip(values) {
h.state_mut().session.execute("prop.addKey", json!({"layer": id.0, "path": path, "time": time, "value": value})).unwrap();
}
}
let uids: Vec<u64> = paths.iter().map(|path| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap().uid).collect();
let ctx = h.ctx.clone();
for command in ["timeline.reveal.position", "timeline.revealAdd.rotation", "timeline.revealAdd.scale"] {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, command, json!({})).unwrap();
}
if graph {
h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
for uid in &uids {
h.state_mut().session.execute("prop.select", json!({"layer": id.0, "prop": uid, "add": true, "selectKeys": false})).unwrap();
}
h.state_mut().ui.timeline.graph_editor = true;
h.state_mut().ui.timeline.graph_mode = "value".into();
}
h.run_steps(4);
let key_positions = |h: &Harness<'_, EffectcraftApp>, uid: u64| {
if !graph {
return keys(h, uid);
}
let mut positions: Vec<Pos2> = h
.state()
.auto
.query(&format!("timeline.graph.key.{uid}.0."))
.iter()
.map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0))
.collect();
positions.sort_by(|a, b| a.x.total_cmp(&b.x));
positions
};
let shift = egui::Modifiers { shift: true, ..Default::default() };
for include_scale in [true, false] {
h.state_mut().session.execute("keys.select", json!({"keys": []})).unwrap();
h.run_steps(2);
let original: Vec<_> =
paths.iter().map(|path| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap().keys.clone()).collect();
let positions = key_positions(&h, uids[0]);
let rotations = key_positions(&h, uids[1]);
let scales = key_positions(&h, uids[2]);
click_with(&mut h, scales[0], Default::default());
click_with(&mut h, positions[0], shift);
click_with(&mut h, rotations[1], shift);
if !include_scale {
click_with(&mut h, scales[1], shift);
click_with(&mut h, scales[0], shift);
click_with(&mut h, scales[1], shift);
}
let count = if include_scale { 3 } else { 2 };
assert_eq!(h.state().session.state.selected_keys.len(), count);
assert_eq!(h.state().session.state.selected_props.len(), 3);
h.input_mut().events.push(Event::Copy);
h.step();
let note = format!("EffectCraft: {count} keyframes");
assert!(h.output().platform_output.commands.iter().any(|command| matches!(command, egui::OutputCommand::CopyText(text) if text == &note)));
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(2.0));
let undo_steps = h.state().session.history.undo.len();
h.input_mut().events.push(Event::Paste(note));
h.run_steps(2);
assert_eq!(h.state().session.history.undo.len(), undo_steps + 1);
for (path, time, value) in
[(paths[0], if include_scale { 2.5 } else { 2.0 }, json!([100.0, 200.0, 0.0])), (paths[1], if include_scale { 3.5 } else { 3.0 }, json!(900.0))]
{
let p = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap();
assert_eq!(p.keys.len(), 3);
assert_eq!(p.keys[2].time.seconds(), time);
assert_eq!(p.keys[2].value.to_json(), value);
}
let scale = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(paths[2]).unwrap();
if include_scale {
assert_eq!(scale.keys.len(), 3);
assert_eq!(scale.keys[2].time.seconds(), 2.0);
assert_eq!(scale.keys[2].value.to_json(), json!([1500.0, 1600.0, 100.0]));
} else {
assert_eq!(scale.keys, original[2]);
}
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
for (path, expected) in paths.iter().zip(&original) {
assert_eq!(&h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap().keys, expected);
}
h.run_steps(2);
}
}
}
#[test]
fn shift_click_toggles_and_ctrl_click_switches_interpolation() {
let (mut h, id, uid) = harness();
@@ -0,0 +1,145 @@
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
};
use effectcraft_engine::{
Session,
media_browser::{Browser, Entry, VirtualTree},
};
use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind};
use egui::{Event, Modifiers, PointerButton, pos2};
use egui_kittest::Harness;
use serde_json::{Value, json};
#[derive(Default)]
struct MutableBrowser {
tree: Mutex<VirtualTree>,
offline: AtomicBool,
}
impl Browser for MutableBrowser {
fn home(&self) -> String {
"/Shoot".into()
}
fn list(&self, dir: &str, only: bool) -> Result<Vec<Entry>, String> {
if self.offline.load(Ordering::Relaxed) {
return Err("Folder temporarily unavailable".into());
}
self.tree.lock().unwrap().list(dir, only)
}
fn actions(&self) -> Vec<(String, String)> {
vec![("refresh".into(), "Refresh".into())]
}
fn action(&self, id: &str) -> Result<Value, String> {
assert_eq!(id, "refresh");
self.tree.lock().unwrap().add_file("/Shoot/From action.csv", "/files/action.csv", 1, None);
Ok(json!({}))
}
}
type Listing = (f64, Arc<Result<Vec<Entry>, String>>);
fn listing(h: &Harness<'_, EffectcraftApp>, only: bool) -> Listing {
let dir = h.state().session.state.media_browser.folder.clone().unwrap_or_else(|| "/Shoot".into());
h.ctx.data(|d| d.get_temp(egui::Id::new(("mb-list", dir, only)))).unwrap()
}
fn names(list: &Arc<Result<Vec<Entry>, String>>) -> Vec<&str> {
list.as_ref().as_ref().unwrap().iter().map(|e| e.name.as_str()).collect()
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
let pos = pos2(e.rect[0] + 8.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::PointerMoved(pos));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
}
fn at(h: &mut Harness<'_, EffectcraftApp>, time: f64) {
h.input_mut().time = Some(time);
h.step();
h.input_mut().time = Some(time);
h.step();
h.input_mut().time = None;
}
#[test]
fn browser_cache_refreshes_filters_navigates_and_recovers_from_errors() {
let browser = Arc::new(MutableBrowser::default());
{
let mut tree = browser.tree.lock().unwrap();
tree.add_file("/Shoot/Clip.csv", "/files/clip.csv", 1, None);
tree.add_file("/Shoot/notes.txt", "/files/notes.txt", 1, None);
tree.add_file("/Shoot/Sub/Child.csv", "/files/child.csv", 1, None);
}
let mut session = Session { browser: Some(browser.clone()), ..Default::default() };
session.prefs.general.language = "en".into();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| {
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.show_panel(PanelKind::MediaBrowser);
app.ui.maximized = Some(PanelKind::MediaBrowser);
app
});
h.run_steps(3);
let (time, first) = listing(&h, false);
assert_eq!(names(&first), ["Sub", "Clip.csv", "notes.txt"]);
assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some());
browser.tree.lock().unwrap().add_file("/Shoot/Added.csv", "/files/added.csv", 2, Some(5));
at(&mut h, time + 0.999);
assert!(Arc::ptr_eq(&first, &listing(&h, false).1));
assert!(h.state().auto.find("mediaBrowser.entry.Added.csv").is_none());
at(&mut h, time + 1.0);
let refreshed = listing(&h, false).1;
assert_eq!(names(&refreshed), ["Sub", "Added.csv", "Clip.csv", "notes.txt"]);
assert!(h.state().auto.find("mediaBrowser.entry.Added.csv").is_some());
assert_eq!(names(&first), ["Sub", "Clip.csv", "notes.txt"]);
click(&mut h, "mediaBrowser.importableOnly");
assert!(h.state().session.state.media_browser.importable_only);
assert_eq!(names(&listing(&h, true).1), ["Sub", "Added.csv", "Clip.csv"]);
assert!(h.state().auto.find("mediaBrowser.entry.notes.txt").is_none());
click(&mut h, "mediaBrowser.importableOnly");
assert!(!h.state().session.state.media_browser.importable_only);
assert!(Arc::ptr_eq(&refreshed, &listing(&h, false).1));
click(&mut h, "mediaBrowser.action.refresh");
assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv", "notes.txt"]);
assert!(h.state().auto.find("mediaBrowser.entry.From action.csv").is_some());
assert_eq!(names(&refreshed), ["Sub", "Added.csv", "Clip.csv", "notes.txt"]);
h.run_steps(40);
click(&mut h, "mediaBrowser.entry.Sub");
click(&mut h, "mediaBrowser.entry.Sub");
assert_eq!(h.state().session.state.media_browser.folder.as_deref(), Some("/Shoot/Sub"));
assert_eq!(names(&listing(&h, false).1), ["Child.csv"]);
assert!(h.state().auto.find("mediaBrowser.entry.Child.csv").is_some());
browser.tree.lock().unwrap().remove("/Shoot/Sub/Child.csv");
let time = listing(&h, false).0;
at(&mut h, time + 1.0);
assert_eq!(names(&listing(&h, false).1), Vec::<&str>::new());
assert!(h.state().auto.find("mediaBrowser.entry.Child.csv").is_none());
click(&mut h, "mediaBrowser.up");
assert_eq!(h.state().session.state.media_browser.folder.as_deref(), Some("/Shoot"));
assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some());
browser.offline.store(true, Ordering::Relaxed);
let time = listing(&h, false).0;
at(&mut h, time + 1.0);
let (time, error) = listing(&h, false);
assert_eq!(error.as_ref(), &Err("Folder temporarily unavailable".into()));
assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Folder temporarily unavailable")));
assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_none());
browser.offline.store(false, Ordering::Relaxed);
browser.tree.lock().unwrap().remove("/Shoot/notes.txt");
at(&mut h, time + 0.999);
assert!(Arc::ptr_eq(&error, &listing(&h, false).1));
at(&mut h, time + 1.0);
assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv"]);
assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some());
if let Ok(path) = std::env::var("MEDIA_BROWSER_SNAPSHOT") {
h.render().unwrap().save(path).unwrap();
}
}
@@ -0,0 +1,125 @@
use std::sync::Arc;
use effectcraft_engine::{
Session,
media_browser::{Browser, Entry, VirtualTree},
};
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind};
use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2};
use egui_kittest::Harness;
use serde_json::{Value, json};
struct TestBrowser(VirtualTree);
impl Browser for TestBrowser {
fn home(&self) -> String {
"/Media".into()
}
fn list(&self, dir: &str, only: bool) -> Result<Vec<Entry>, String> {
self.0.list(dir, only)
}
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, button: PointerButton) -> Vec<String> {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
let pos = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::PointerMoved(pos));
h.step();
let mut copied = Vec::new();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE });
h.step();
copied.extend(h.output().platform_output.commands.iter().filter_map(|c| match c {
OutputCommand::CopyText(text) => Some(text.clone()),
_ => None,
}));
}
copied
}
#[test]
fn file_folder_and_virtual_paths_copy_from_the_context_menu_without_document_changes() {
let cases = [
("作品 clip.csv", "/Users/example/Shoot 01/作品 clip.csv"),
("Notes.txt", "./relative notes.txt"),
("Virtual.csv", "browser://assets/opaque%20作品.csv?version=2"),
("Folder 作品", "/Media/Folder 作品"),
];
let mut tree = VirtualTree::default();
for (name, path) in cases.iter().take(3) {
tree.add_file(&format!("/Media/{name}"), path, 1, None);
}
tree.add_dir("/Media/Folder 作品");
let mut session = Session { browser: Some(Arc::new(TestBrowser(tree))), ..Default::default() };
session.prefs.general.language = "en".into();
session.execute("comp.new", json!({"width": 64, "height": 36, "duration": 2})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| {
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.show_panel(PanelKind::MediaBrowser);
app.ui.maximized = Some(PanelKind::MediaBrowser);
app
});
h.run_steps(3);
let project = h.state().session.project.clone();
let revision = h.state().session.revision;
let history = h.state().session.history.undo.len();
let browser = serde_json::to_value(&h.state().session.state.media_browser).unwrap();
for (name, path) in cases {
let id = format!("mediaBrowser.entry.{name}.copySourcePath");
assert!(h.state().auto.find(&id).is_none());
assert!(click(&mut h, &format!("mediaBrowser.entry.{name}"), PointerButton::Secondary).is_empty());
h.run_steps(3);
assert!(h.state().auto.find(&id).is_some());
if name == "作品 clip.csv"
&& let Ok(file) = std::env::var("MEDIA_BROWSER_COPY_SNAPSHOT")
{
h.render().unwrap().save(file).unwrap();
}
assert_eq!(click(&mut h, &id, PointerButton::Primary), [path]);
h.run_steps(3);
assert!(h.state().auto.find(&id).is_none());
assert_eq!(h.state().session.project, project);
assert_eq!(h.state().session.revision, revision);
assert_eq!(h.state().session.history.undo.len(), history);
assert_eq!(serde_json::to_value(&h.state().session.state.media_browser).unwrap(), browser);
}
}
#[test]
fn copy_path_control_command_preserves_the_path_and_rejects_bad_arguments() {
let mut app = EffectcraftApp::new(Session::default());
let ctx = egui::Context::default();
let project = app.session.project.clone();
let revision = app.session.revision;
let history = app.session.history.undo.len();
for params in [json!({"path": " ./作品 clip.csv "}), json!({}), json!({"path": ""}), json!({"path": 42}), json!({"path": null})] {
let mut reply = None;
let mut out = ctx.run_ui(Default::default(), |ui| {
let (request, _rx) = ControlRequest::new("ui.menu.invoke", json!({"id": "mediaBrowser.copySourcePath", "params": params}));
reply = match control::handle(&mut app, ui.ctx(), &request) {
control::Outcome::Done(value) => Some(value),
_ => panic!("expected immediate reply"),
};
});
out.textures_delta.clear();
let copied: Vec<_> = out
.platform_output
.commands
.iter()
.filter_map(|c| match c {
OutputCommand::CopyText(text) => Some(text.as_str()),
_ => None,
})
.collect();
if params.get("path").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) {
assert_eq!(reply, Some(json!({"ok": true, "result": {"path": " ./作品 clip.csv "}})));
assert_eq!(copied, [" ./作品 clip.csv "]);
} else {
assert_eq!(reply, Some(json!({"ok": false, "error": "A source path is required"})));
assert!(copied.is_empty());
}
assert_eq!(app.session.project, project);
assert_eq!(app.session.revision, revision);
assert_eq!(app.session.history.undo.len(), history);
}
}
+19
View File
@@ -225,3 +225,22 @@ fn import_dialog_offers_mxf() {
assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}");
assert!(offered.iter().any(|e| e == "mov"));
}
/// #1059: opening the demo from the start screen and immediately pressing Spacebar previews
/// the demo — the start screen's keyboard focus must not outlive it.
#[test]
fn space_previews_after_opening_the_demo_from_home() {
let mut h = {
let s = Session::default();
let mut app = EffectcraftApp::new(s);
app.ui.start_screen = true;
Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| app)
};
h.run_steps(3);
let demo = rect(&h, "home.file.openDemoProject");
click_at(&mut h, demo.center());
assert!(h.state().session.active_comp().is_some(), "the demo comp is open");
assert!(!h.state().ui.start_screen, "the start screen closed");
key(&mut h, Key::Space);
assert!(h.state().playback.playing, "Space previews the demo (#1059)");
}
+86
View File
@@ -125,6 +125,15 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.run_steps(2);
}
fn double_click_at(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) {
h.input_mut().events.push(Event::PointerMoved(pos));
h.step();
for pressed in [true, false, true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.run_steps(3);
}
fn hover(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.run_steps(3);
@@ -506,6 +515,83 @@ fn properties_scale_link_shares_the_timeline_state() {
assert!(!h.state().ui.timeline.unlinked.contains(&uid), "re-linking clears the shared state");
}
#[test]
fn properties_label_double_click_reveals_without_editing() {
let mut h = harness();
let layer = h.state().session.active_comp().unwrap().layers[0].id;
let other = h.state_mut().session.execute("layer.newSolid", json!({"name": "Other", "color": "#204060"})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().session.execute("prop.addKey", json!({"layer": layer.0, "path": "transform/opacity", "time": 1.0, "value": 50.0})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [layer.0]})).unwrap();
let comp = h.state().session.active_comp().unwrap();
let opacity = comp.layer(layer).unwrap().props.prop("transform/opacity").unwrap().uid;
let other_scale = comp.layers.iter().find(|l| l.id.0 == other).unwrap().props.prop("transform/scale").unwrap().uid;
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::frontend(h.state_mut(), &ctx, "timeline.revealProps", json!({"props": [{"layer": other, "prop": other_scale}]})).unwrap();
open(&mut h, "properties");
open(&mut h, "renderQueue");
h.state_mut().ui.timeline.search = "no matching property".into();
h.run_steps(3);
let value = h.state().auto.find(&format!("properties.prop.{opacity}.value")).unwrap().rect;
let label = pos2(value[0] - 60.0, value[1] + value[3] / 2.0);
let row = format!("timeline.prop.{opacity}.row");
click_at(&mut h, label);
assert_eq!(h.state().ui.timeline.search, "no matching property");
assert!(h.state().auto.find(&row).is_none());
h.run_steps(30);
let before = serde_json::to_value(h.state().session.project.as_ref()).unwrap();
let undo = h.state().session.history.undo.len();
let selected = h.state().session.state.selected_layers.clone();
for _ in 0..2 {
double_click_at(&mut h, label);
assert!(h.state().auto.find(&row).is_some(), "Opacity is visible for keyframing");
assert!(h.state().auto.find(&format!("timeline.prop.{other_scale}.row")).is_some(), "the other layer keeps its reveal");
assert_eq!(h.state().ui.timeline.search, "");
h.run_steps(30);
}
assert_eq!(serde_json::to_value(h.state().session.project.as_ref()).unwrap(), before, "revealing preserves values and keyframes");
assert_eq!(h.state().session.history.undo.len(), undo);
assert_eq!(h.state().session.state.selected_layers, selected);
}
#[test]
fn properties_reveal_scrolls_to_the_displayed_layer_in_a_multiple_selection() {
let mut h = harness();
let layer = h.state().session.active_comp().unwrap().layers[0].id;
for _ in 0..40 {
h.state_mut().session.execute("layer.newSolid", json!({"name": "Spacer", "color": "#204060"})).unwrap();
}
let top = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.select", json!({"layers": [layer.0, top.0]})).unwrap();
let uid = h.state().session.active_comp().unwrap().layer(layer).unwrap().props.prop("transform/opacity").unwrap().uid;
open(&mut h, "properties");
h.state_mut().toggle_maximize(PanelKind::Properties);
h.run_steps(3);
let r = h.state().auto.find(&format!("properties.prop.{uid}.label")).unwrap().rect;
double_click_at(&mut h, pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0));
assert!(h.state().auto.find(&format!("timeline.prop.{uid}.row")).is_some(), "the displayed layer's property is in view");
assert_eq!(h.state().session.state.selected_layers, [layer, top]);
}
#[test]
fn properties_transform_labels_reveal_the_corresponding_property() {
let mut h = harness();
let layer = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.select", json!({"layers": [layer.0]})).unwrap();
let props: Vec<u64> =
h.state().session.active_comp().unwrap().layer(layer).unwrap().transform().unwrap().props().filter(|p| !p.three_d_only).map(|p| p.uid).collect();
open(&mut h, "properties");
for uid in &props {
h.state_mut().ui.timeline.open_layers.remove(&layer.0);
h.run_steps(30);
assert!(h.state().auto.find(&format!("timeline.prop.{uid}.row")).is_none());
let r = h.state().auto.find(&format!("properties.prop.{uid}.label")).unwrap().rect;
double_click_at(&mut h, pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0));
for candidate in &props {
assert_eq!(h.state().auto.find(&format!("timeline.prop.{candidate}.row")).is_some(), candidate == uid);
}
}
}
/// Gap 7: the Backgrounds category is listed under * Animation Presets and a preset applies on double-click.
#[test]
fn background_presets_are_listed_and_apply() {
+96 -20
View File
@@ -1,6 +1,7 @@
//! Effects & Presets: copy the configured folder path through the contents menu.
use effectcraft_engine::Session;
use effectcraft_ui_egui::panels::effects_presets::PresetFile;
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind};
use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2};
use egui_kittest::Harness;
@@ -18,8 +19,9 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> Vec<String> {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
let pos = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
let mut copied = Vec::new();
h.input_mut().events.push(Event::PointerMoved(pos));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(pos));
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
for command in &h.output().platform_output.commands {
@@ -34,26 +36,29 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> Vec<String> {
#[test]
fn presets_menu_copies_the_configured_folder_without_editing_the_project() {
let expected = expected_folder();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| {
let mut app = EffectcraftApp::new(Session::default());
app.ui.start_screen = false;
app.show_panel(PanelKind::EffectsPresets);
app
});
h.run_steps(3);
let before = serde_json::to_value(&h.state().session.project).unwrap();
let revision = h.state().session.revision;
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
click(&mut h, "effects.menu");
h.run_steps(3);
if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") {
h.render().unwrap().save(path).unwrap();
for language in ["en", "ja"] {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| {
let mut app = EffectcraftApp::new(Session::default());
app.session.prefs.general.language = language.into();
app.ui.start_screen = false;
app.show_panel(PanelKind::EffectsPresets);
app
});
h.run_steps(3);
let before = serde_json::to_value(&h.state().session.project).unwrap();
let revision = h.state().session.revision;
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
click(&mut h, "effects.menu");
h.run_steps(3);
if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") {
h.render().unwrap().save(path).unwrap();
}
assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected.as_str()]);
h.run_steps(2);
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
assert_eq!(serde_json::to_value(&h.state().session.project).unwrap(), before);
assert_eq!(h.state().session.revision, revision);
}
assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected]);
h.run_steps(2);
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
assert_eq!(serde_json::to_value(&h.state().session.project).unwrap(), before);
assert_eq!(h.state().session.revision, revision);
}
#[test]
@@ -88,3 +93,74 @@ fn presets_folder_control_command_returns_the_path_or_a_configuration_error() {
assert_eq!(copied, [expected.as_str()]);
}
}
#[test]
fn shared_preset_rows_search_apply_and_undo_the_selected_definition() {
struct Files(std::path::PathBuf);
impl Drop for Files {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let files = Files(std::env::temp_dir().join(format!(
"ec-preset-rows-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
)));
std::fs::create_dir(&files.0).unwrap();
let mut entries = Vec::new();
for (name, value) in [("Custom Cache A", 35.0), ("Custom Cache B", 70.0)] {
let path = files.0.join(format!("{name}.ecpreset"));
let preset = json!({"effectcraftAnimationPreset": 1, "name": name, "properties": [{"path": "transform/opacity", "value": {"t": "Scalar", "v": value}}], "effects": []});
std::fs::write(&path, serde_json::to_vec(&preset).unwrap()).unwrap();
entries.push(PresetFile { name: name.into(), path: path.to_string_lossy().into_owned() });
}
let mut session = Session::default();
session.prefs.general.language = "en".into();
session.execute("comp.new", json!({"width": 64, "height": 36, "duration": 2})).unwrap();
session.execute("layer.newText", json!({"text": "Cache"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| {
let mut app = EffectcraftApp::new(session);
app.ui.start_screen = false;
app.show_panel(PanelKind::EffectsPresets);
app
});
h.run_steps(3);
let shared = std::sync::Arc::new(entries);
let now = h.ctx.input(|i| i.time);
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("fx-user-presets"), (now, shared.clone())));
let search = |h: &mut Harness<'_, EffectcraftApp>, text: &str| {
click(h, "effects.search");
h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
h.input_mut().events.push(Event::Text(text.into()));
h.run_steps(40);
};
search(&mut h, "Typewriter");
assert!(h.state().auto.find("effects.preset.text.typewriter").is_some());
assert!(h.state().auto.find("effects.preset.file.Custom Cache A").is_none());
click(&mut h, "effects.preset.text.typewriter");
click(&mut h, "effects.preset.text.typewriter");
h.run_steps(3);
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().groups().count(), 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
for (name, expected) in [("Custom Cache A", 35.0), ("Custom Cache B", 70.0)] {
search(&mut h, name);
let id = format!("effects.preset.file.{name}");
let history = h.state().session.history.undo.len();
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), 100.0);
click(&mut h, &id);
click(&mut h, &id);
h.run_steps(3);
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), expected);
assert_eq!(h.state().session.history.undo.len(), history + 1);
if let Ok(dir) = std::env::var("PRESET_CACHE_SNAPSHOTS") {
h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap();
}
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), 100.0);
}
let (_, cached) = h.ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
assert!(std::sync::Arc::ptr_eq(&shared, &cached));
assert_eq!(shared.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Custom Cache A", "Custom Cache B"]);
}
+4 -2
View File
@@ -121,8 +121,10 @@ fn viewer_click_does_not_pick_through_hidden_or_locked_solo_layers() {
}
}
/// Solo is not time-dependent (as in After Effects): outside the soloed layer's in-out range the
/// viewer shows nothing, so neither the soloed layer nor the others can be picked.
#[test]
fn viewer_click_ignores_inactive_solo_layers() {
fn viewer_click_picks_nothing_while_the_solo_layer_is_inactive() {
let mut h = selection_harness();
let comp = h.state().session.active_comp().unwrap();
let (front, back) = (comp.layers[0].id, comp.layers[1].id);
@@ -133,7 +135,7 @@ fn viewer_click_ignores_inactive_solo_layers() {
h.run_steps(3);
let at = screen(&h, [320.0, 180.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![back]);
assert!(h.state().session.state.selected_layers.is_empty(), "{back:?} stays excluded by solo");
}
/// A selected layer under the pointer takes the press before the layers in front of it, as in
+9 -9
View File
@@ -10,15 +10,6 @@ user can do, an agent can do too. There are three ways in:
| **CLI** (`effectcraft-cli exec/get/set/...`) | one-shot scripting and CI; JSON output with `--json` | no |
| **Control channel** (`effectcraft --control 9877`) | driving and seeing the live UI; see [control-protocol.md](control-protocol.md) | yes |
## Find the presets folder
The native Effects & Presets panel's options menu includes **Copy Presets Folder Path**. It copies
the folder the panel scans: `EC_PRESETS_DIR` when set, otherwise `~/Documents/EffectCraft/Presets`
using `HOME` or `USERPROFILE`. Relative paths are resolved to an absolute path. The folder may
not exist yet; this action does not create it.
Over the live UI control channel, `ui.menu.invoke {"id":"effects.copyPresetsFolderPath"}` copies
the same path and returns `{path}`. The web app reports that it has no local presets folder.
## MCP setup
Build once with `cargo build --release -p effectcraft-cli`, then register the server. For Claude
@@ -719,3 +710,12 @@ open the demo project; without it the app starts with an empty project) and use
mode or the raw control channel. A good loop is: `ui_elements` to find an id, `ui_click` or `ui_drag`
to act, then `screenshot {"panel":"Timeline"}` to check the result. `render_frame` shows the
composition itself at any zoom.
### Effects & Presets
The native Effects & Presets panel's options menu includes **Copy Presets Folder Path**. It copies
the folder the panel scans: `EC_PRESETS_DIR` when set, otherwise `~/Documents/EffectCraft/Presets`
using `HOME` or `USERPROFILE`. Relative paths are resolved to an absolute path. The folder may
not exist yet; this action does not create it.
Over the live UI control channel, `ui.menu.invoke {"id":"effects.copyPresetsFolderPath"}` copies
the same path and returns `{path}`. The web app reports that it has no local presets folder.
+1
View File
@@ -37,6 +37,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params
|---|---|---|
| `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. |
| `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). |
| Copy a Media Browser source path | `ui.menu.invoke {id: "mediaBrowser.copySourcePath", params: {path}}` | Copies the exact non-empty path string to the clipboard and returns `{path}`. Supports file/folder paths and virtual source identifiers. Missing, empty or non-string paths return an error. |
| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.<kind>` / `timeline.revealAdd.<kind>` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.<anchor\|position\|scale\|rotation\|opacity>` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` (Ctrl/Cmd+`), `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). |
| `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` |
| `ui.menu.list` | `{}` | The menu tree with enablement. |
+1 -1
View File
@@ -149,7 +149,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Corner Pin | `ec.distort.cornerpin` | 4 | GPU | 32 | Implemented |
| Detail-preserving Upscale | `ec.distort.upscale` | 4 | | 32 | Implemented |
| Displacement Map | `ec.distort.displacementmap` | 8 | GPU | 32 | Implemented |
| Liquify | `ec.distort.liquify` | 12 | GPU | 32 | Implemented |
| Liquify | `ec.distort.liquify` | 13 | GPU | 32 | Implemented |
| Magnify | `ec.distort.magnify` | 10 | GPU | 32 | Implemented |
| Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented |
| Mirror | `ec.distort.mirror` | 2 | GPU | 32 | Implemented |
+1 -1
View File
@@ -43,7 +43,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| TIFF | R/W | yes | yes (sequence) | good |
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) |
| Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec |
| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS |
| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes; Composition and Composition - Retain Layer Sizes, #1536) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS |
| SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't |
| GIF | R/W | yes | yes (animated) | good |
| BMP | R | yes | — | good |
+2 -1
View File
@@ -117,7 +117,8 @@ The most important missing piece: it turns every other estimate here into a meas
- Regression evidence for [#66](https://github.com/storytold/effectcraft/issues/66): scripted
pointer tests in `ui_viewer` and `ui_text_edit`, checked headlessly on Linux, cover viewer clicks,
marquee selection and text picking when layers are hidden or excluded by solo. They also check
hidden/locked solo layers and solo layers outside their active time range.
hidden/locked solo layers, and that solo still excludes the other layers while the soloed
layer is outside its active time range (as in After Effects, so nothing is picked there).
- Regression evidence for [#67](https://github.com/storytold/effectcraft/issues/67): scripted
input tests in `ui_project_delete`, checked headlessly on Linux, cover Delete/Backspace on
Project items, multiple selection and undo. They check that an unrelated Timeline layer stays,
+1 -1
View File
@@ -426,7 +426,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Layers | 88% | 4.8 | motion blur of collapsed precomps (their own motion and the parent camera's) and of animated layer content (lock-aware layer commands, delete keeping children in place, step-by-step Arrange, Transform keeping depth and separated dimensions and links kept on duplicate / paste landed in M3.11; frame blending, collapse transformations and slip edit in M4.3–M4.4) |
| Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control; M13.4: HEVC export (MP4 `hvc1`, `effectcraft-hevcenc`: Main / Main 10, IDR + P slices, quarter-pel motion, deblocking, bit-exact with ffmpeg) and AV1 export (MP4 `av01` and WebM, `effectcraft-av1enc`: 8/10-bit key + inter frames, quarter-pel motion, loop filter, bit-exact with libdav1d) with profile, level, bitrate / constant-quality and key-frame interval options; Opus SILK (NB/MB/WB, mid/side stereo) and hybrid (SWB/FB) modes chosen by bitrate and Audio/Voice tuning, with the Opus bitrate in the Output Module. HEVC/AV1 import decodes through FilmCraft (MP4/MOV/MKV/WebM); WebM VP9 alpha (BlockAdditions) imports as straight alpha. Left: HEVC/AV1 have no B-frames, multi-reference, SAO/CDEF/restoration or alpha (compression below mature encoders); Opus has no FEC/DTX and only 20 ms frames; Photoshop sequence output, overflow for movies only checks at file creation |
| Audio | 85% | 0.5 | audio to keyframes |
| Import | 97% | 0.5 | JPX / JBIG2 images and predefined non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.12: mesh shadings — free-form / lattice Gouraud triangles, Coons and tensor patches — and function-based shadings, CCITT G3 / G4 images, knockout / isolated transparency groups with group alpha and soft masks on the group's result, `/W2` vertical metrics, EPS text with embedded Type 1 / CFF or bundled fonts, and Create Shapes from Vector Layer keeping images as parented footage layers in paint order; M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) |
| Import | 97% | 0.5 | JPX / JBIG2 images and predefined non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.12: mesh shadings — free-form / lattice Gouraud triangles, Coons and tensor patches — and function-based shadings, CCITT G3 / G4 images, knockout / isolated transparency groups with group alpha and soft masks on the group's result, `/W2` vertical metrics, EPS text with embedded Type 1 / CFF or bundled fonts, and Create Shapes from Vector Layer keeping images as parented footage layers in paint order; M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier; PDF / AI Composition - Retain Layer Sizes crops each layer to its artwork in place, #1536) |
| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates; nested comp markers on the precomp layer bar (Preserve frame rate / resolution when nested and nested comps showing nothing past their end landed in M4.9–M4.10; Pre-compose keeping the comp's settings and New Comp from Selection's dialog in M3.12; CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
+6
View File
@@ -70,3 +70,9 @@ date, load average, scenario, settings, metrics); `--compare OLD.jsonl` prints t
the latest matching runs there, `--compare OLD --with NEW` compares two files without rendering.
The table notes the 1-minute load average: on a busy machine wall time is noise, so compare
peak memory and instructions retired, and rerun when the load differs a lot.
Results files allow blank lines; corrupt records or invalid comparison fields report the file
and line instead of being discarded. `--fail-over PCT` requires the same run keys on both sides,
successful latest runs, and at least one comparable resource metric per run. Failed latest runs,
missing coverage, empty results and wall-time-only measurements cannot pass the gate. Omit
`--fail-over` for an exploratory partial comparison; failed latest runs remain visible.
@@ -37,8 +37,8 @@
</keywords>
<screenshots>
<screenshot type="default">
<caption>Editing The Great Wave off Kanagawa with type layers and adjustment layers</caption>
<image>https://raw.githubusercontent.com/storytold/effectcraft/main/docs/images/effectcraft-demo.jpg</image>
<caption>Demo composition with text layers, timeline and properties</caption>
<image type="source">https://raw.githubusercontent.com/storytold/effectcraft/05955c08b4fd025f1a09882df72e6dbff1a8148a/docs/images/effectcraft-hero.png</image>
</screenshot>
</screenshots>
<branding>
+10 -3
View File
@@ -44,10 +44,17 @@ overrides:
- mesa-vulkan-drivers
- xdg-desktop-portal
rpm:
# Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL
# and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package). The glibc
# floor names libm because the binaries' GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's
# glibc 2.34 provides libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses.
depends:
- libxkbcommon
- (vulkan-loader or libglvnd-egl)
- alsa-lib
- libc.so.6()(64bit)
- libm.so.6(GLIBC_2.35)(64bit)
- libgcc_s.so.1()(64bit)
- libxkbcommon.so.0()(64bit)
- (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit))
- libasound.so.2()(64bit)
recommends:
- libwayland-client
- libX11
+32 -7
View File
@@ -138,15 +138,40 @@ resvg --skip-system-fonts -w 1320 "$HERE/dmg/background.svg" "$WORK/background@2
sips -s dpiWidth 144 -s dpiHeight 144 "$WORK/background@2x.png" >/dev/null
tiffutil -cathidpicheck "$WORK/background.png" "$WORK/background@2x.png" -out "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
hdiutil makehybrid -hfs -hfs-volume-name "EffectCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
rm -f "$DMG"
# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a
# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or
# similar detritus" on the app in the image and on a copy installed from it (as VectorCraft's
# storytold/vectorcraft#1085 and PdfCraft's storytold/pdfcraft#823). `create` attaches a device
# while it copies, which fails now and then on CI runners ("Resource busy"), so it gets a few
# tries. The volume name has no version: .DS_Store finds the background through an alias that
# includes it.
# Usage: hdiutil_retry <what> <hdiutil args…>; tries five times with growing pauses.
hdiutil_retry() {
local what="$1" attempt
shift
for attempt in 1 2 3 4 5; do
if hdiutil "$@"; then
return 0
fi
[ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2
sleep $((attempt * 10))
done
}
hdiutil_retry create create -srcfolder "$STAGE" -volname "EffectCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG"
sign "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
# The app inside the image must still pass --strict, so a makehybrid-style regression can't ship.
MOUNT="$WORK/dmg-check"
rm -rf "$MOUNT"
mkdir -p "$MOUNT"
hdiutil_retry attach attach -nobrowse -readonly -mountpoint "$MOUNT" "$DMG" >/dev/null
if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/EffectCraft.app"; then
hdiutil_retry detach detach "$MOUNT" >/dev/null || true
exit 1
fi
hdiutil_retry detach detach "$MOUNT" >/dev/null
if [ "$NOTARIZE" = 1 ]; then
notarize "$DMG"
xcrun stapler staple "$DMG"
+78 -6
View File
@@ -32,7 +32,8 @@ Run it under `nice -n 10` on a busy machine; the table notes the load average of
--with NEW.jsonl with --compare: compare two result files, without rendering
--fail-over PCT with --compare: exit 1 when a matched run got more than PCT % worse in
CPU time, peak memory, max RSS or instructions retired (wall time is
left out: it follows the machine's load)
left out: it follows the machine's load). Requires matching successful
latest runs and comparable resource measurements for every key
Scenarios: demo (the built-in demo comp), layers (1080p, 100 solid/text/shape layers with Gaussian
Blur, Glow, Levels, Curves, Drop Shadow and keyframes), precomps (static and animated precomps
@@ -744,7 +745,49 @@ fn append_line(path: &Path, v: &Value) -> Result<(), String> {
fn read_jsonl(path: &Path) -> Result<Vec<Value>, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(text.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect())
text.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(index, line)| {
let context = |error| format!("{}:{}: {error}", path.display(), index.saturating_add(1));
let row: Value = serde_json::from_str(line).map_err(|error| context(format!("invalid JSON: {error}")))?;
validate_row(&row).map_err(context)?;
Ok(row)
})
.collect()
}
/// Validate comparison keys and measurements; unavailable platform metrics may be null/absent.
fn validate_row(row: &Value) -> Result<(), String> {
for (field, value) in [
("scenario", &row["scenario"]),
("run", &row["run"]),
("settings.resolution", &row["settings"]["resolution"]),
("settings.format", &row["settings"]["format"]),
] {
if value.as_str().is_none_or(|text| text.trim().is_empty()) {
return Err(format!("`{field}` must be a nonempty string"));
}
}
if row["settings"]["frames"].as_u64().is_none() {
return Err("`settings.frames` must be a nonnegative integer".into());
}
if row["ok"].as_bool().is_none() {
return Err("`ok` must be a boolean".into());
}
if !row["metrics"].is_object() {
return Err("`metrics` must be an object".into());
}
if row["metrics"]["wall_s"].is_null() {
return Err("`metrics.wall_s` is required".into());
}
for (field, _) in COMPARED {
let value = &row["metrics"][*field];
if !value.is_null() && value.as_f64().is_none_or(|number| !number.is_finite() || number < 0.0) {
return Err(format!("`metrics.{field}` must be a finite nonnegative number or null"));
}
}
Ok(())
}
// ---------------------------------------------------------------------------------------- output
@@ -816,10 +859,10 @@ fn key_of(v: &Value) -> String {
)
}
/// The last successful result per key (a results file accumulates runs; the latest one counts).
/// The latest result per key, including failures that supersede older successful runs.
fn latest_per_key(rows: &[Value]) -> BTreeMap<String, Value> {
let mut map = BTreeMap::new();
for r in rows.iter().filter(|r| r["ok"] == true) {
for r in rows {
map.insert(key_of(r), r.clone());
}
map
@@ -857,6 +900,31 @@ fn regressions(old: &BTreeMap<String, Value>, new: &BTreeMap<String, Value>, lim
fn check_regressions(old: &BTreeMap<String, Value>, new: &BTreeMap<String, Value>, limit: Option<f64>) -> Result<(), String> {
let Some(limit) = limit else { return Ok(()) };
if old.is_empty() || new.is_empty() {
return Err("insufficient benchmark evidence: baseline and candidate must contain runs".into());
}
let mut missing = vec![];
for (key, baseline) in old {
match new.get(key) {
None => missing.push(format!("missing candidate run: {key}")),
Some(candidate) => {
for (label, row) in [("baseline", baseline), ("candidate", candidate)] {
if row["ok"] != true {
missing.push(format!("failed latest {label} run: {key}"));
}
}
if !GATED.iter().any(|field| pct(baseline["metrics"][*field].as_f64(), candidate["metrics"][*field].as_f64()).is_some()) {
missing.push(format!("no comparable resource measurements: {key}"));
}
}
}
}
for key in new.keys().filter(|key| !old.contains_key(*key)) {
missing.push(format!("missing baseline run: {key}"));
}
if !missing.is_empty() {
return Err(format!("insufficient benchmark evidence:\n {}", missing.join("\n ")));
}
let bad = regressions(old, new, limit);
if bad.is_empty() {
println!("no run got more than {limit}% worse");
@@ -883,6 +951,10 @@ fn compare_table(old: &BTreeMap<String, Value>, new: &BTreeMap<String, Value>) -
n["run"].as_str().unwrap_or(""),
n["settings"]["frames"].as_u64().unwrap_or(0)
));
if o["ok"] != true || n["ok"] != true {
s.push_str(&format!(" (failed latest {} run)\n", if n["ok"] != true { "candidate" } else { "baseline" }));
continue;
}
for (field, _) in COMPARED {
s.push_str(&format!(" {:>8}", fmt_pct(pct(o["metrics"][field].as_f64(), n["metrics"][field].as_f64()))));
}
@@ -963,11 +1035,11 @@ mod tests {
assert_eq!(fmt_pct(Some(25.0)), "+25.0%");
assert_eq!(fmt_pct(None), "-");
// The latest successful run per key is the baseline; failed runs don't count.
// A successful run supersedes an earlier failure for the same key.
let old = latest_per_key(&[
row("layers", "full", 1.0, None, false),
row("layers", "full", 20.0, Some(1000), true),
row("layers", "full", 10.0, Some(2000), true),
row("layers", "full", 1.0, None, false),
]);
assert_eq!(old.len(), 1);
let new = latest_per_key(&[row("layers", "full", 8.0, Some(1500), true), row("demo", "half", 1.0, None, true)]);
+134
View File
@@ -0,0 +1,134 @@
//! Regression gates must fail when benchmark evidence is corrupt or incomplete.
use serde_json::{Value, json};
use std::path::PathBuf;
use std::process::{Command, Output};
struct ResultsDir(PathBuf);
impl ResultsDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!("effectcraft-bench-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn compare(&self, old: &str, new: &str, gate: bool) -> Output {
let baseline = self.0.join("baseline.jsonl");
let candidate = self.0.join("candidate.jsonl");
std::fs::write(&baseline, old).unwrap();
std::fs::write(&candidate, new).unwrap();
let mut command = Command::new(env!("CARGO_BIN_EXE_xtask"));
command.args(["bench", "--compare"]).arg(baseline).arg("--with").arg(candidate);
if gate {
command.args(["--fail-over", "5"]);
}
command.output().unwrap()
}
}
impl Drop for ResultsDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn row(scenario: &str, ok: bool) -> Value {
json!({"scenario": scenario, "run": "full", "ok": ok,
"settings": {"resolution": "full", "frames": 60, "format": "png"},
"metrics": {"wall_s": 1.0, "cpu_s": 2.0, "peak_footprint_bytes": 1000, "max_rss_bytes": null, "instructions": null}})
}
fn diagnostic(output: &Output) -> String {
format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr))
}
#[test]
fn corrupt_records_report_the_input_file_and_line() {
let files = ResultsDir::new();
let valid = row("demo", true);
let mut missing = valid.clone();
missing["settings"].as_object_mut().unwrap().remove("frames");
let mut missing_wall = valid.clone();
missing_wall["metrics"].as_object_mut().unwrap().remove("wall_s");
let mut bad_metric = valid.clone();
bad_metric["metrics"]["cpu_s"] = json!("not a measurement");
let mut negative_metric = valid.clone();
negative_metric["metrics"]["cpu_s"] = json!(-1);
for bad in [
"{".into(),
"null".into(),
"[]".into(),
"{}".into(),
missing.to_string(),
missing_wall.to_string(),
bad_metric.to_string(),
negative_metric.to_string(),
] {
let output = files.compare(&format!("{valid}\n"), &format!("{valid}\n\n \n{bad}\n"), true);
let message = diagnostic(&output);
assert!(!output.status.success(), "corrupt input passed: {bad}\n{message}");
assert!(message.contains("candidate.jsonl:4:"), "{message}");
}
let output = files.compare("{\n", &format!("{valid}\n"), false);
assert!(!output.status.success());
assert!(diagnostic(&output).contains("baseline.jsonl:1:"));
}
#[test]
fn gates_reject_failed_latest_runs_and_missing_coverage() {
let files = ResultsDir::new();
let good = row("demo", true);
let failed = row("demo", false);
let other = row("layers", true);
for (old, new) in [
(good.to_string(), String::new()),
(String::new(), String::new()),
(good.to_string(), failed.to_string()),
(good.to_string(), format!("{good}\n{failed}")),
(format!("{good}\n{failed}"), good.to_string()),
(good.to_string(), other.to_string()),
(format!("{good}\n{other}"), good.to_string()),
(good.to_string(), format!("{good}\n{other}")),
] {
let output = files.compare(&old, &new, true);
assert!(!output.status.success(), "incomplete gate passed: {old} -> {new}\n{}", diagnostic(&output));
}
}
#[test]
fn gates_require_a_comparable_resource_metric_for_every_run() {
let files = ResultsDir::new();
let good = row("demo", true);
let mut wall_only = good.clone();
wall_only["metrics"]["cpu_s"] = Value::Null;
wall_only["metrics"]["peak_footprint_bytes"] = Value::Null;
let output = files.compare(&good.to_string(), &wall_only.to_string(), true);
assert!(!output.status.success(), "{}", diagnostic(&output));
assert!(diagnostic(&output).contains("comparable"));
let mut zero = good.clone();
zero["metrics"]["cpu_s"] = json!(0);
zero["metrics"]["peak_footprint_bytes"] = json!(0);
assert!(!files.compare(&zero.to_string(), &good.to_string(), true).status.success());
let mut mixed = row("layers", true);
mixed["metrics"] = wall_only["metrics"].clone();
assert!(!files.compare(&format!("{good}\n{mixed}"), &format!("{good}\n{mixed}"), true).status.success());
}
#[test]
fn valid_results_keep_comparison_and_exploratory_mode() {
let files = ResultsDir::new();
let good = row("demo", true);
let failed = row("demo", false);
let output = files.compare(&format!("\n{failed}\n{good}\n"), &format!("{good}\n \n"), true);
assert!(output.status.success(), "{}", diagnostic(&output));
assert!(diagnostic(&output).contains("+0.0%"));
let mut open = good.clone();
open["run"] = json!("open");
open["settings"]["resolution"] = json!("-");
open["settings"]["frames"] = json!(0);
assert!(files.compare(&open.to_string(), &open.to_string(), true).status.success(), "project-open runs have zero rendered frames");
let mut regression = good.clone();
regression["metrics"]["cpu_s"] = json!(3);
assert!(!files.compare(&good.to_string(), &regression.to_string(), true).status.success());
let output = files.compare(&good.to_string(), &format!("{good}\n{failed}"), false);
assert!(output.status.success(), "exploratory comparison stays available");
assert!(diagnostic(&output).contains("failed"), "latest failure is visible: {}", diagnostic(&output));
}