mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-21 19:22:52 -05:00
Merge remote-tracking branch 'origin/main' into pr-1608
# Conflicts: # contributors/people.toml
This commit is contained in:
commit
2dcceeea90
92 files changed
+2399
-234
No files matched your search
@@ -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
@@ -1455,6 +1455,7 @@ dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})?;
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 | ||||