Merge remote-tracking branch 'origin/main' into menu-bar-native-behaviour

# Conflicts:
#	crates/ui-egui/src/panels/timeline.rs
This commit is contained in:
Egon 5000
2026-10-19 22:59:07 +02:00
158 changed files with 7943 additions and 602 deletions
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
Generated
+44 -36
View File
@@ -968,6 +968,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-pdf"
version = "0.1.0"
@@ -1158,7 +1163,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1183,7 +1188,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1195,14 +1200,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1218,7 +1223,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -1226,7 +1231,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1243,8 +1248,9 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
"effectcraft-effects",
"effectcraft-geom",
@@ -1276,7 +1282,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1306,7 +1312,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1323,7 +1329,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -1331,7 +1337,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1353,14 +1359,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1385,7 +1391,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1404,7 +1410,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1416,8 +1422,9 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1433,8 +1440,9 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"craft-docpath",
"effectcraft-model",
"effectcraft-pdf",
"effectcraft-project",
@@ -1461,7 +1469,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-geom",
"image",
@@ -1473,14 +1481,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1492,7 +1500,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1508,7 +1516,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1520,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1533,14 +1541,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1549,7 +1557,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1571,7 +1579,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1588,7 +1596,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1597,7 +1605,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1609,7 +1617,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1621,7 +1629,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -1629,7 +1637,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1641,7 +1649,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"craft-ui",
"effectcraft-color",
@@ -1673,11 +1681,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.10.0"
version = "0.12.0"
[[package]]
name = "effectcraft-web"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6617,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.12.0"
dependencies = [
"serde_json",
]
+2 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.10.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/effectcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
effectcraft-time = { path = "crates/time" }
effectcraft-geom = { path = "crates/geom" }
+4
View File
@@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
## Progress log
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
path into an editable Path 1 with the same geometry, as After Effects does; it used to
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
and its vertices could not be edited or animated (#932).
- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking;
strokes and undo history are preserved so restoring the timing allows the span edit again.
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
+18
View File
@@ -16,6 +16,8 @@
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
//! [--progress] report progress on stderr when it is not a terminal, up to four lines a second:
//! `progress <frames done> <frames total> <seconds elapsed> <seconds left, or - when unknown>`
//! effectcraft-cli render --template T.ectemplate [--values V.json] --out FILE
//! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png]
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
@@ -106,6 +108,8 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
a relative --out is relative to the working directory
--progress: progress lines on stderr when it is not a terminal
(progress <frames done> <total> <seconds elapsed> <seconds left|->)
render/render-frame --template T.ectemplate [--values V.json] render one template instance
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
--play N renders N consecutive frames with/without the layer cache;
@@ -189,6 +193,7 @@ const FLAGS: &[&str] = &[
"--small",
"--adv3d",
"--autosave",
"--progress",
];
struct Args {
@@ -772,8 +777,21 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
}
s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?;
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
// `--progress`: one line per update for a program reading stderr (a terminal gets the live line).
let lines = args.flag("--progress") && !tty;
let (mut last_line, mut last_done) = (std::time::Instant::now(), u64::MAX);
while s.is_rendering() {
std::thread::sleep(std::time::Duration::from_millis(100));
if lines
&& last_line.elapsed() >= std::time::Duration::from_millis(250)
&& let Some(p) = s.render_progress()
&& p.done != last_done
{
last_line = std::time::Instant::now();
last_done = p.done;
let left = p.remaining.map_or_else(|| "-".to_string(), |r| format!("{r:.1}"));
note!("progress {} {} {:.1} {left}", p.done, p.total, p.item_elapsed);
}
if tty && let Some(p) = s.render_progress() {
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
let _ = write!(
+40 -4
View File
@@ -4,7 +4,7 @@ use std::io::Read;
use effectcraft_engine::{
EngineError, Session,
project::{ItemId, LayerId},
project::{ItemId, LayerId, essential::EgKind},
};
use serde_json::{Map, Value, json};
@@ -92,7 +92,7 @@ fn preflight(s: &Session, path: &str, values: &Map<String, Value>) -> Result<(),
project.fix_next_id();
let mut check = Session::new();
check.project = project.into();
Ok(instantiate(&mut check, source, values))
Ok(instantiate(&mut check, source, values, false))
})
.map_err(|e| Failure::Error(e.to_string()))?
}
@@ -105,10 +105,27 @@ pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> {
}
let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?;
let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?;
instantiate(s, source, &values)
instantiate(s, source, &values, true)
}
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Result<(), Failure> {
/// The file a Media Replacement control is given: a path, or `{path|file|media: path}`.
fn media_path(value: &Value) -> Option<&str> {
match value {
Value::String(path) => Some(path.as_str()),
Value::Object(m) => ["path", "file", "media"].iter().find_map(|k| m.get(*k)).and_then(Value::as_str),
_ => None,
}
}
/// Whether the template's control of that name (looked up as `essential.set` does) is a Media Replacement.
fn is_media_control(s: &Session, source: u64, name: &str) -> bool {
let Some(eg) = s.project.comp(ItemId(source)).and_then(|c| c.essential.as_ref()) else { return false };
let all = eg.flat();
let control = all.iter().find(|c| c.name == name).or_else(|| all.iter().find(|c| c.name.eq_ignore_ascii_case(name)));
control.is_some_and(|c| matches!(c.kind, EgKind::Media { .. }))
}
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>, import_media: bool) -> Result<(), Failure> {
// Duplicate and empty the source with commands: this preserves exact rational frame rate,
// Tick duration, background, work area and renderer settings without round-tripping floats.
let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?;
@@ -145,6 +162,25 @@ fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Res
// Let the wrapper carry the source composition's authored motion blur.
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?;
for (control, value) in values {
if is_media_control(s, source, control) {
let path = media_path(value).ok_or_else(|| Failure::Error(format!("control `{control}` is a media control: give it a file path")))?;
// The preflight session cannot import files; the real run reports a bad path.
if !import_media {
continue;
}
let imported = execute(s, "file.import", json!({"paths": [path]}))?;
if let Some(err) = imported.get("errors").and_then(Value::as_array).and_then(|e| e.first()) {
return Err(Failure::Error(format!("control `{control}`: cannot import {path}: {err}")));
}
let item = imported
.get("items")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(Value::as_u64)
.ok_or_else(|| Failure::Error(format!("control `{control}`: {path} did not import as footage")))?;
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "item": item}))?;
continue;
}
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?;
}
Ok(())
+74
View File
@@ -593,3 +593,77 @@ solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraph
assert_eq!(reply["error"], "command `renderQueue.add` is not available right now: select or open a composition", "{reply}");
let _ = std::fs::remove_dir_all(&root);
}
/// `WGPU_ADAPTER_NAME` that matches nothing is a clean `--gpu` error listing the adapters
/// found (wgpu's own helper panics). Read once per process, hence a subprocess.
#[test]
fn gpu_adapter_name_mismatch_is_a_clean_error() {
let out = bin()
.args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("nomatch.png").to_str().unwrap(), "--json"])
.env("WGPU_ADAPTER_NAME", "NoSuchAdapter-xyz")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code(), Some(1), "stdout: {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr));
let v: Value = serde_json::from_str(stdout.trim()).unwrap();
let e = v["error"].as_str().unwrap();
assert!(e.contains("WGPU_ADAPTER_NAME=\"NoSuchAdapter-xyz\""), "{e}");
assert!(e.contains("available: [") || e.contains("no adapters on the enabled backends"), "{e}");
}
/// An exported-but-empty `WGPU_ADAPTER_NAME` behaves like unset: it never produces the
/// adapter-name error (whether a GPU exists on this machine is not asserted).
#[test]
fn gpu_adapter_name_empty_is_unset() {
let out = bin()
.args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("emptyname.png").to_str().unwrap(), "--json"])
.env("WGPU_ADAPTER_NAME", " ")
.output()
.unwrap();
let all = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(!all.contains("WGPU_ADAPTER_NAME"), "{all}");
}
/// Renders two seconds of the demo at quarter resolution (long enough for several progress
/// updates) and returns stderr, which `output()` reads through a pipe: not a terminal.
fn render_demo_stderr(name: &str, extra: &[&str]) -> String {
let out = tmp(name);
let mut args = vec!["render", "--demo", "--format", "gif", "--start", "0", "--end", "2", "--resolution", "quarter", "--out", out.to_str().unwrap()];
args.extend_from_slice(extra);
let o = bin().args(&args).output().unwrap();
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
assert!(o.status.success(), "{args:?}: {stderr}");
let _ = std::fs::remove_file(out);
stderr
}
/// `render --progress` with a piped stderr reports the render's frames as
/// `progress <frames done> <frames total> <seconds elapsed> <seconds left|->` lines, one per
/// change (#1778).
#[test]
fn render_progress_reports_frames_on_a_piped_stderr() {
let stderr = render_demo_stderr("progress.gif", &["--progress"]);
let mut last_done = None;
let mut totals = vec![];
for line in stderr.lines().filter(|l| l.starts_with("progress")) {
let fields: Vec<&str> = line.split(' ').collect();
assert_eq!(fields.len(), 5, "{line}");
let done: u64 = fields[1].parse().unwrap_or_else(|e| panic!("{line}: frames done: {e}"));
let total: u64 = fields[2].parse().unwrap_or_else(|e| panic!("{line}: frames total: {e}"));
let elapsed: f64 = fields[3].parse().unwrap_or_else(|e| panic!("{line}: seconds elapsed: {e}"));
assert!(fields[4] == "-" || fields[4].parse::<f64>().is_ok_and(|left| left >= 0.0), "{line}: seconds left");
assert!(done <= total && total > 0 && elapsed >= 0.0, "{line}");
assert!(last_done.is_none_or(|last| done > last), "one line per change, and frames only go up: {stderr}");
last_done = Some(done);
totals.push(total);
}
assert!(!totals.is_empty(), "no progress line on a piped stderr: {stderr}");
assert!(totals.iter().all(|&t| t == totals[0]), "one render, one total: {stderr}");
}
/// Without `--progress`, the same render writes no progress lines to a piped stderr (#1778).
#[test]
fn render_without_progress_writes_no_progress_lines() {
let stderr = render_demo_stderr("no-progress.gif", &[]);
assert!(!stderr.lines().any(|l| l.starts_with("progress")), "{stderr}");
}
+78
View File
@@ -689,3 +689,81 @@ fn template_frame_output_cannot_alias_its_values_through_a_hard_link() {
assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A template whose `Clip` control is a Media Replacement showing `source.png` (blue), with
/// `other.png` (red) beside it to replace it with, exported as `lt.ectemplate` in a fresh `dir`.
fn media_template(case: &str) -> PathBuf {
let temp_root = std::env::temp_dir().canonicalize().unwrap();
let dir = temp_root.join(format!("ec-qa-template-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 20, 40, 255])).save(dir.join("other.png")).unwrap();
let imported = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
let item = imported["result"]["items"][0].as_u64().unwrap();
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
let added = ok(&dir, &["exec", "layer.addItem", &params, "t.ecproj", "--save"]);
let layer = added["result"]["layer"].as_u64().unwrap();
let params = serde_json::json!({"comp": "Lower Third", "layer": layer, "name": "Clip"}).to_string();
ok(&dir, &["exec", "essential.addMedia", &params, "t.ecproj", "--save"]);
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
dir
}
/// Renders the template's frame with `values` and checks that the control shows `other.png`.
fn assert_renders_the_replacement(dir: &PathBuf, values: &str) {
std::fs::write(dir.join("values.json"), values).unwrap();
ok(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "out.png"]);
let px = image::open(dir.join("out.png")).unwrap().to_rgba8().get_pixel(8, 8).0;
assert!(
(px[0] as i32 - 200).abs() <= 2 && (px[1] as i32 - 20).abs() <= 2 && (px[2] as i32 - 40).abs() <= 2,
"{values}: replacement media rendered: {px:?}"
);
}
#[test]
fn template_values_fill_a_media_replacement_control_with_a_file_path() {
let dir = media_template("media-control");
assert_renders_the_replacement(&dir, r#"{"Clip":"other.png"}"#);
// A control that is not given a path is rejected with a clear message.
std::fs::write(dir.join("bad.json"), r#"{"Clip":5}"#).unwrap();
let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "bad.json", "--time", "1", "--out", "bad.png"]);
assert_eq!(code, 1, "{reply}");
assert!(reply["error"].as_str().unwrap().contains("media control"), "{reply}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The object forms `{"path"|"file"|"media": …}` take a file path too, and the control is found
/// by name the way `essential.set` finds it: exactly, then ignoring case (#1787).
#[test]
fn template_media_control_takes_a_path_object_and_a_name_in_any_case() {
let dir = media_template("media-forms");
for values in [
r#"{"Clip":{"path":"other.png"}}"#,
r#"{"Clip":{"file":"other.png"}}"#,
r#"{"Clip":{"media":"other.png"}}"#,
r#"{"clip":"other.png"}"#,
r#"{"CLIP":{"path":"other.png"}}"#,
] {
let _ = std::fs::remove_file(dir.join("out.png"));
assert_renders_the_replacement(&dir, values);
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A media path that cannot be imported fails the render with an error naming the control and
/// the path, and writes no frame (#1787).
#[test]
fn template_media_control_reports_a_path_that_cannot_be_imported() {
let dir = media_template("media-missing");
std::fs::write(dir.join("missing.json"), r#"{"Clip":"missing.png"}"#).unwrap();
let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing.json", "--time", "1", "--out", "missing-out.png"]);
assert_eq!(code, 1, "{reply}");
let error = reply["error"].as_str().unwrap();
assert!(error.contains("control `Clip`: cannot import missing.png"), "{reply}");
assert!(!dir.join("missing-out.png").exists(), "{reply}");
let _ = std::fs::remove_dir_all(&dir);
}
+44 -6
View File
@@ -57,6 +57,26 @@ function req(r) {
return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
}
// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has
// committed. A request's success doesn't mean the write is stored: the transaction can still
// abort afterwards (quota at commit, the browser closing), rolling it back, and a write
// reported as saved then is never retried (#1242). A failed request aborts the transaction
// with its error; an explicit abort leaves no error, hence the AbortError.
function write(db, store, op) {
return new Promise((res, rej) => {
let tx = null;
try {
tx = db.transaction(store, "readwrite");
tx.oncomplete = () => res();
tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError"));
op(tx.objectStore(store));
} catch (e) {
try { tx?.abort(); } catch {}
rej(e);
}
});
}
async function idb() {
if (!self.indexedDB) throw new Error("no IndexedDB");
const open = indexedDB.open(DB, 1);
@@ -70,10 +90,10 @@ async function idb() {
return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified }));
},
async put(key, data, modified) {
await req(tx("readwrite").put({ key, data: data.slice().buffer, modified }));
await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified }));
},
async del(key) {
await req(tx("readwrite").delete(key));
await write(db, STORE, (s) => s.delete(key));
},
};
}
@@ -315,7 +335,15 @@ function spawn(base) {
/// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download),
/// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message).
export function workerRun(id, json, files, base, onMessage) {
const w = idle.pop() ?? spawn(base);
let w;
try {
w = idle.pop() ?? spawn(base);
} catch (e) {
// `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job
// fails like one whose worker didn't start.
setTimeout(() => onMessage("error", String((e && e.message) || e), null, null));
return;
}
running.set(id, w);
w.ready.then(() => {
w.worker.onmessage = (e) => {
@@ -380,7 +408,17 @@ const frameWorkers = [];
/// GPU effects on it.
export function frameWorkerStart(base, onMessage, gpu) {
const index = frameWorkers.length;
const worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
let worker;
try {
worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
} catch (e) {
// The constructor throws at once when the page's origin is opaque (a sandboxed iframe). The
// slot stays, and the failure is reported like a worker that died, after the caller has
// recorded the index: frames then render on the page's thread (as with `?noworkers`).
frameWorkers.push(null);
setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed")));
return index;
}
worker.onmessage = (e) => {
const m = e.data;
if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
@@ -550,7 +588,7 @@ async function openFolder(handle, remember) {
if (remember) {
try {
const db = await handlesDb();
await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle }));
await write(db, "folders", (s) => s.put({ name, handle }));
} catch (e) { console.info("media browser: cannot remember the folder", e); }
}
return { name, entries };
@@ -602,7 +640,7 @@ export async function browseForget(name) {
folders.delete(name);
try {
const db = await handlesDb();
await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name));
await write(db, "folders", (s) => s.delete(name));
} catch {}
}
+180
View File
@@ -0,0 +1,180 @@
// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no
// browser or Wasm build needed:
//
// node apps/effectcraft-web/tests/storage.mjs
//
// A write is acknowledged only once its transaction has committed, not when its request
// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be
// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure.
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tick = () => new Promise((r) => setTimeout(r, 0));
globalThis.self = globalThis;
// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite
// transaction commits once its requests are done, unless it was aborted (which rolls back its
// writes). `fault` makes the next request do something else.
let fault = null; // "abortAfterSuccess" | "requestError" | "throw"
const databases = new Map();
class Request {
constructor(tx) {
this.transaction = tx;
this.result = undefined;
this.error = null;
}
}
class Store {
constructor(tx, rows) {
this.tx = tx;
this.rows = rows;
}
request(apply) {
const tx = this.tx;
if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError");
const f = fault;
fault = null;
if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError");
const r = new Request(tx);
tx.pending++;
setTimeout(() => {
tx.pending--;
if (tx.finished) return;
if (f === "requestError") {
r.error = new DOMException("the key already exists", "ConstraintError");
const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
r.onerror?.(e);
tx.onerror?.(e);
if (!e.defaultPrevented) tx.fail(r.error);
return;
}
r.result = apply(tx.staged);
r.onsuccess?.({ target: r });
if (f === "abortAfterSuccess") tx.abort();
tx.maybeCommit();
}, 0);
return r;
}
put(v) {
return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; });
}
delete(k) {
return this.request((rows) => { rows.delete(k); });
}
get(k) {
return this.request((rows) => rows.get(k));
}
getAll() {
return this.request((rows) => [...rows.values()]);
}
}
class Transaction {
constructor(db, name) {
this.db = db;
this.name = name;
this.staged = new Map(db.stores.get(name));
this.pending = 0;
this.finished = false;
this.error = null;
setTimeout(() => this.maybeCommit(), 0);
}
objectStore(name) {
const s = new Store(this, this.staged);
s.keyPath = this.db.keyPaths.get(name);
return s;
}
maybeCommit() {
if (this.finished || this.pending > 0) return;
this.finished = true;
this.db.stores.set(this.name, this.staged);
setTimeout(() => this.oncomplete?.(), 0);
}
fail(error) {
this.error = error;
this.abort();
}
abort() {
if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError");
this.finished = true;
setTimeout(() => this.onabort?.(), 0);
}
}
class Database {
constructor() {
this.stores = new Map();
this.keyPaths = new Map();
}
createObjectStore(name, { keyPath }) {
this.stores.set(name, new Map());
this.keyPaths.set(name, keyPath);
}
transaction(name) {
if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError");
return new Transaction(this, name);
}
}
globalThis.indexedDB = {
open(name) {
const r = new Request(null);
setTimeout(() => {
let db = databases.get(name);
if (!db) {
db = new Database();
databases.set(name, db);
r.result = db;
r.onupgradeneeded?.();
}
r.result = db;
r.onsuccess?.({ target: r });
}, 0);
return r;
},
};
const host = await import(pathToFileURL(join(here, "../js/host.js")));
assert.equal(await host.storeOpen("indexeddb"), "indexeddb");
const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key);
const bytes = (s) => new TextEncoder().encode(s);
const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data));
// A healthy write resolves once committed, and the store has it then.
await host.storePut("project.json", bytes("v1"), 1);
assert.equal(text(stored("project.json")), "v1", "a resolved put is committed");
// The put's request succeeds, then the transaction aborts: the put fails, the old value stays.
fault = "abortAfterSuccess";
await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported");
assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back");
// The same for a delete: it fails and the entry stays.
fault = "abortAfterSuccess";
await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported");
assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back");
// A failed request aborts its transaction with the request's error.
fault = "requestError";
await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" });
assert.equal(text(stored("project.json")), "v1");
// A request that throws at once (a value that can't be stored) fails the write.
fault = "throw";
await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" });
assert.equal(text(stored("project.json")), "v1");
// The retry after a failure commits the latest value, and a later delete removes it.
await host.storePut("project.json", bytes("v5"), 5);
assert.equal(text(stored("project.json")), "v5");
await host.storeDelete("project.json");
assert.equal(stored("project.json"), undefined, "a resolved delete is committed");
const all = await host.storeLoadAll();
assert.deepEqual(all, [], "nothing else was written");
await tick();
console.log("storage: ok");
+31
View File
@@ -7,6 +7,8 @@
// (#218), and not when it is unchanged.
// - A worker whose start fails fails its job and is terminated, instead of the job waiting for
// "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start.
// - A `new Worker` that throws (a sandboxed iframe's opaque origin, #1586) fails the job, and a
// frame worker is reported dead, instead of the exception escaping into startup.
// Exits non-zero on failure.
import assert from "node:assert/strict";
import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs";
@@ -67,6 +69,35 @@ assert.equal(failed.kind, "error");
assert.ok(workers[1].terminated, "the failed worker is terminated");
assert.equal(host.workerStats().running, 0);
// A `new Worker` that throws (a sandboxed iframe's opaque origin): the job fails, and a frame
// worker is reported dead, instead of the exception escaping into startup.
failStart = false;
const RealWorker = globalThis.Worker;
globalThis.Worker = class {
constructor() {
throw new Error("SecurityError: cannot be accessed from origin 'null'");
}
};
// (Take the idle workers out of play first, so that a new one has to be built.)
for (let n = host.workerStats().idle, id = 100; n > 0; n--, id++) {
host.workerRun(id, "{}", [], "http://localhost/", () => {});
host.workerCancel(id);
}
assert.equal(host.workerStats().idle, 0);
const threw = await run(6, []);
assert.equal(threw.kind, "error");
assert.match(threw.json, /SecurityError/);
assert.equal(host.workerStats().running, 0);
let frameCall = null;
const frameDone = new Promise((resolve) => {
frameCall = host.frameWorkerStart("http://localhost/", (index, json, bytes, error) => resolve({ index, error }), false);
});
const dead = await frameDone;
assert.equal(dead.index, frameCall, "the dead worker keeps its index");
assert.match(dead.error, /SecurityError/);
host.frameWorkerPost(frameCall, { type: "frame" }); // no worker behind it: ignored
globalThis.Worker = RealWorker;
// #216, worker side: a rejected start is re-raised where the page's `onerror` sees it.
const dir = mkdtempSync(join(tmpdir(), "ec-worker-"));
copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js"));
+15 -3
View File
@@ -71,7 +71,13 @@
...ec.info(),
crossOriginIsolated: self.crossOriginIsolated,
webgpu: !!navigator.gpu,
serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller),
serviceWorker: (() => {
try {
return !!(navigator.serviceWorker && navigator.serviceWorker.controller);
} catch {
return false;
}
})(),
load: window.effectcraftLoad,
}),
// Browser storage (OPFS / IndexedDB): save without downloading, list, remove, wait for writes.
@@ -83,8 +89,14 @@
workerFrameCheck: (params) => ec.workerFrameCheck(params ?? {}).then(JSON.parse),
};
// Offline start and install (PWA): the service worker caches the app's files.
if ("serviceWorker" in navigator && !new URLSearchParams(location.search).has("nosw")) {
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
if (!new URLSearchParams(location.search).has("nosw")) {
try {
if ("serviceWorker" in navigator && navigator.serviceWorker) {
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
}
} catch (e) {
console.warn("service worker:", e);
}
}
try {
// Compile once: the page and its render workers instantiate the same module.
+34 -5
View File
@@ -23,8 +23,8 @@ mod control_server;
mod launch_guard;
#[cfg(target_os = "macos")]
mod native_menu;
// Built everywhere so it stays checked; only the macOS app uses it.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
// Built everywhere so it stays checked; the macOS and Linux apps use it.
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
mod sheet_dialogs;
mod window;
@@ -122,6 +122,22 @@ fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
}
}
/// The commands' file dialogs open without blocking the frame where the host's backend would:
/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop
/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its
/// answer without pumping events, so the window stops answering the compositor and the desktop
/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let ctx = ctx.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let _ = (app, ctx);
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
@@ -301,11 +317,11 @@ fn main() -> eframe::Result {
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
}));
// The commands' file dialogs don't block the frame where the platform's backend
// would (#511, #1275); see [`install_file_dialogs`].
install_file_dialogs(&mut app, &cc.egui_ctx);
#[cfg(target_os = "macos")]
{
// The commands' file dialogs open as sheets instead of nested modal loops (#511).
let ctx = cc.egui_ctx.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
app.hooks.app_action = Some(Box::new(native_menu::app_action));
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
}
@@ -597,4 +613,17 @@ mod tests {
assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
}
}
/// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
/// compositor while it is open, so the commands' file dialogs go through the non-blocking
/// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
#[test]
fn commands_file_dialogs_do_not_block_the_frame() {
let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
super::install_file_dialogs(&mut app, &egui::Context::default());
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
}
}
+7 -3
View File
@@ -1,10 +1,14 @@
//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`.
//! File dialogs that don't block the frame (macOS and Linux), for `Hooks::file_dialog_async`.
//!
//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is
//! inside winit's event handler. On macOS a file dragged over the window while that loop ran
//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app
//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once;
//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then.
//! (#511). On Linux the blocking dialog is worse than slow: rfd uses the XDG desktop portal, so
//! the main thread sits waiting on the portal's answer and never pumps winit's events — the
//! window stops answering the compositor and the desktop offers to force-quit the app while the
//! Open dialog is up (#1275). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window
//! (macOS) or through the portal from a worker thread (Linux) and returns at once; the answer
//! arrives when the dialog closes, outside the frame, and the app runs the command then.
use effectcraft_ui_egui::{DialogDone, FileDialog};
use rfd::{AsyncFileDialog, FileHandle};
+9 -1
View File
@@ -66,7 +66,7 @@ sync_github_name = true
display_name = "Jason Brashear"
[people.nanox333]
display_name = "nanox333"
display_name = "nnaannoo"
[people.Nael-Nathanael]
sync_github_name = true
@@ -87,3 +87,11 @@ sync_github_name = false
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.krinry]
display_name = "krinry"
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+30 -2
View File
@@ -301,7 +301,8 @@ impl OffsetCurve {
}
let n = ext.len();
// Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat
// segments so the curve never overshoots its points.
// segments, and at most three times either slope (Fritsch-Carlson) so the curve never
// overshoots its points, even beside a short steep segment.
let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6);
let m: Vec<f32> = (0..n)
.map(|k| {
@@ -309,7 +310,12 @@ impl OffsetCurve {
return 0.0;
}
let (a, b) = (slope(k - 1), slope(k));
if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 }
if a * b <= 0.0 {
0.0
} else {
let lim = 3.0 * a.abs().min(b.abs());
((a + b) * 0.5).clamp(-lim, lim)
}
})
.collect();
let eval = |x: f32| -> f32 {
@@ -989,6 +995,28 @@ mod tests {
}
}
#[test]
fn hue_saturation_curve_stays_between_its_points() {
// #1656: a point dragged to the bottom right next to another one. The steep short
// segment used to pull the long one beside it far outside the box (|offset| > 0.5).
// The third pair sits across the hue wrap (0.98 and 0.005 are neighbours).
let cases = [("0.3,0.4 0.31,0 0.7,0.6", true), ("0.1,0.3 0.3,0.5 0.31,1 0.7,0.5", false), ("0.5,0.6 0.98,0.4 0.005,0", true)];
for (pts, periodic) in cases {
let c = OffsetCurve::parse(pts, periodic).unwrap();
for i in 0..=1000 {
let x = i as f32 / 1000.0;
let v = c.at(x);
assert!((-0.5..=0.5).contains(&v), "{pts} at {x}: {v}");
}
for p in parse_points(pts) {
assert!((c.at(p.0) - (p.1 - 0.5)).abs() < 0.02, "{pts}: passes through {p:?}");
}
if periodic {
assert!((c.at(0.0) - c.at(1.0)).abs() < 1e-4, "{pts}: no seam at the wrap");
}
}
}
fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
}
+18 -2
View File
@@ -164,7 +164,10 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
let kind = ctx.params.e("waveType");
let pin = ctx.params.e("pinning");
let seed = ctx.seed ^ 0x3a7e;
let (dx, dy) = (dir.sin(), -dir.cos());
// Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave
// boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU.
let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v };
let (dx, dy) = (axis(dir.sin()), -axis(dir.cos()));
if !ctx.adjustment {
b.pad(h.abs().ceil() as u32 + 1);
}
@@ -556,8 +559,21 @@ mod tests {
let (a, b) = (o.img.get(pad, y + pad), im.get(0, y));
assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}");
}
let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im);
let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone());
assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b)));
// #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row,
// Bottom Edge (6) the last, and the rest of the layer still moves.
for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] {
let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)];
let o = run("ec.distort.wavewarp", &set, im.clone());
let pad = ((o.img.width - im.width) / 2) as i64;
for x in 0..40 {
let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row));
assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}");
}
let mid = 15;
assert!((0..40).any(|x| differs(o.img.get(x + pad, mid + pad), im.get(x, mid))), "pin {edge_row}: the middle of the layer must still distort");
}
}
#[test]
+21 -1
View File
@@ -363,7 +363,9 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay);
let d = qx * nx + qy * ny;
let ad = d.abs();
if ad > half + soft_w {
// The wire is the segment A to B, not an endless line.
let along = qx * tx + qy * ty;
if !(0.0..=len).contains(&along) || ad > half + soft_w {
continue;
}
// Positions just outside the wire on both sides.
@@ -580,6 +582,24 @@ mod tests {
}
}
#[test]
fn wire_removal_stays_between_a_and_b() {
let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]);
for y in 0..20 {
img.set(10, y, [0.0, 0.0, 0.0, 1.0]);
}
let out = run_fx(
"ec.key.ccsimplewireremoval",
&[("pointA", Value::Vec2([10.5, 5.0])), ("pointB", Value::Vec2([10.5, 15.0])), ("removalStyle", Value::Enum(0)), ("thickness", num(3.0))],
img.clone(),
0.0,
EffectEnv::default(),
);
assert!((out.img.get(10, 10)[0] - 0.5).abs() < 0.05, "inside the segment");
assert_eq!(out.img.get(10, 2), img.get(10, 2), "before A");
assert_eq!(out.img.get(10, 18), img.get(10, 18), "after B");
}
#[test]
fn wire_removal_frame_offset_uses_neighbour_frame() {
use crate::{EffectHost, LayerPixels};
+1
View File
@@ -10,6 +10,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
craft-docpath = { workspace = true }
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }
+14 -2
View File
@@ -47,6 +47,11 @@ pub struct AutoSaveState {
#[derive(Clone)]
pub struct PendingWrite {
pub path: PathBuf,
pub(crate) slot: u32,
pub(crate) revision: u64,
/// Originating project identity: the path changes on Save As, the epoch on Open/New.
pub(crate) project_path: Option<String>,
pub(crate) project_epoch: u64,
handle: std::sync::Arc<std::sync::Mutex<Option<std::thread::JoinHandle<std::io::Result<()>>>>>,
}
@@ -57,9 +62,16 @@ impl std::fmt::Debug for PendingWrite {
}
impl PendingWrite {
pub fn spawn(path: PathBuf, work: impl FnOnce() -> std::io::Result<()> + Send + 'static) -> std::io::Result<PendingWrite> {
pub fn spawn(
path: PathBuf,
slot: u32,
revision: u64,
project_path: Option<String>,
project_epoch: u64,
work: impl FnOnce() -> std::io::Result<()> + Send + 'static,
) -> std::io::Result<PendingWrite> {
let h = std::thread::Builder::new().name("autosave".into()).spawn(work)?;
Ok(PendingWrite { path, handle: std::sync::Arc::new(std::sync::Mutex::new(Some(h))) })
Ok(PendingWrite { path, slot, revision, project_path, project_epoch, handle: std::sync::Arc::new(std::sync::Mutex::new(Some(h))) })
}
pub fn is_finished(&self) -> bool {
self.handle.lock().map(|h| h.as_ref().is_none_or(|h| h.is_finished())).unwrap_or(true)
+1 -1
View File
@@ -182,7 +182,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
let video = match p.get("videoStart").filter(|v| v.as_str() != Some("")) {
Some(v) => parse_stamp(v, fps).ok_or_else(|| bad(c, "videoStart: ISO 8601 date-time, hh:mm:ss, timecode or seconds"))?,
None => {
let (created, _, _) = crate::media_browser::file_dates(&footage.path);
let (created, _, _) = if footage.missing { (None, None, None) } else { crate::media_browser::file_dates(&footage.path) };
Stamp { secs: created.ok_or_else(|| bad(c, "the footage has no recording time; pass `videoStart`"))? as f64, dated: true }
}
};
+89 -2
View File
@@ -1,8 +1,8 @@
//! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key
//! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs.
use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease};
use effectcraft_project::{LayerId, Property, Uid};
use effectcraft_keyframe::{Ease, Interp, Keyframe, ShapePath, Value as KValue, key_at, retime_roving, side_ease};
use effectcraft_project::{ItemId, LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -61,6 +61,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if clip.is_empty() {
return Err(bad("keys.paste", "no keyframes have been copied"));
}
// A copied Mask Path / shape Path key pasted onto Position becomes a motion path (#1425).
if let [c] = clip.as_slice()
&& let Some(KValue::Path(path)) = c.keys.first().map(|k| &k.value)
&& let Some(target) = path_target(s, p)?
{
return paste_path(s, p, target, path);
}
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = time_p(s, p, Some(comp));
@@ -134,6 +141,86 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// The spatial property (Position, Anchor Point…) a copied path pastes onto as a motion path:
/// the `prop` / `path` given, else a selected one of the target layers. `None` when there's none.
pub(super) fn path_target(s: &Session, p: &Value) -> Result<Option<(ItemId, LayerId, Uid)>> {
let (cid, targets) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let motion = |pr: &Property| pr.spatial && !pr.static_only && matches!(pr.value, KValue::Vec2(_) | KValue::Vec3(_));
let explicit = targets.first().and_then(|lid| {
let l = comp.layer(*lid)?;
let pr = match p.get("prop").and_then(Value::as_u64) {
Some(u) => l.props.find(u),
None => l.props.prop(str_p(p, "path")?),
}?;
Some((*lid, pr))
});
if p.get("prop").is_some() || p.get("path").is_some() {
return Ok(explicit.filter(|(_, pr)| motion(pr)).map(|(lid, pr)| (cid, lid, pr.uid)));
}
let selected = s
.state
.selected_props
.iter()
.filter(|(lid, _)| targets.contains(lid))
.find(|(lid, uid)| comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(motion));
Ok(selected.map(|(lid, uid)| (cid, *lid, *uid)))
}
/// Paste a mask or shape path onto a spatial property as After Effects does: a keyframe per
/// vertex with the path's tangents, from the CTI over 2 s (a closed path ends back on its first
/// vertex), the inner keys roving so the layer moves at one speed. Like After Effects, the
/// vertices are pasted as they are (layer-space numbers, not converted to the target's space).
pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, LayerId, Uid), path: &ShapePath) -> Result<Value> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = time_p(s, p, Some(comp));
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
if path.closed && !order.is_empty() {
order.push(0);
}
if order.is_empty() {
return Err(bad("keys.paste", "the copied path has no points"));
}
let span = Tick::from_seconds_f64(2.0).0 / i64::try_from(order.len().saturating_sub(1).max(1)).unwrap_or(i64::MAX);
let at = |xy: Option<&[f64; 2]>| {
let [x, y] = xy.copied().unwrap_or_default();
[x, y, 0.0]
};
let last = order.len() - 1;
let keys: Vec<(Tick, KValue, [f64; 3], [f64; 3], bool)> = order
.iter()
.enumerate()
.map(|(i, &v)| {
let [x, y, z] = at(path.vertices.get(v));
let value = match target.props.find(uid).map(|pr| &pr.value) {
Some(KValue::Vec2(_)) => KValue::Vec2([x, y]),
_ => KValue::Vec3([x, y, z]),
};
let time = target.layer_time(t + Tick(span.saturating_mul(i64::try_from(i).unwrap_or(i64::MAX))));
(time, value, at(path.in_tangents.get(v)), at(path.out_tangents.get(v)), i != 0 && i != last)
})
.collect();
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
let mut sel = vec![];
for (time, value, spatial_in, spatial_out, roving) in keys {
let k = Keyframe { spatial_in, spatial_out, spatial_auto: false, roving, ..Keyframe::new(time, value) };
match pr.keys.binary_search_by(|x| x.time.cmp(&time)) {
Ok(i) => pr.keys[i] = k,
Err(i) => pr.keys.insert(i, k),
}
sel.push(time);
}
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
retime_roving(&mut pr.keys, pr.spatial);
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
Ok(json!(st.selected_keys.len()))
})
}
/// Select every keyframe of the selected properties, or of the given / selected layers.
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, mut layers) = layers_p(s, p)?;
+3 -2
View File
@@ -10,7 +10,7 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project,
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, selected_leaf_props, str_p};
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd};
/// Preset file format version tag (`*.ecpreset`, JSON).
@@ -147,7 +147,8 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
}
}
for (path, src) in &props {
match l.props.prop_mut(path) {
let resolved = resolve_effect_path(l, path);
match l.props.prop_mut(&resolved) {
Some(pr) if std::mem::discriminant(&pr.value) == std::mem::discriminant(&src.value) => {
pr.value = src.value.clone();
pr.keys = src
+89 -6
View File
@@ -115,6 +115,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
if !items.is_empty() {
return super::prop_groups::duplicate_contents(s, &items);
}
// Text animators or their selectors selected → duplicate them inside their text layers.
let items = super::prop_groups::selected_animator_items(s);
if !items.is_empty() {
return super::prop_groups::duplicate_contents(s, &items);
}
}
let (cid, ids) = layers_p(s, p)?;
let new = s.edit("Duplicate", None, |proj, st| {
@@ -328,7 +333,8 @@ fn paste_properties(s: &mut Session, p: &Value) -> Result<Value> {
}
let lt = l.layer_time(t);
for (path, v) in &values {
if let Some(pr) = l.props.prop_mut(path)
let path = super::resolve_effect_path(l, path);
if let Some(pr) = l.props.prop_mut(&path)
&& let Some(v) = pr.value.coerce_json(&v.to_json())
{
pr.set_value_at(lt, v);
@@ -458,7 +464,8 @@ fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
for lid in &ids {
let l = super::layer_mut(proj, cid, *lid)?;
for (path, e) in &pairs {
if let Some(pr) = l.props.prop_mut(path) {
let path = super::resolve_effect_path(l, path);
if let Some(pr) = l.props.prop_mut(&path) {
pr.expr = Some(e.clone());
n += 1;
}
@@ -496,6 +503,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(clip) = s.state.link_clipboard.clone() {
return paste_links(s, clip);
}
// A copied Mask Path / shape Path pasted onto Position becomes a motion path (#1425).
if let [(_, effectcraft_keyframe::Value::Path(path))] = s.state.value_clipboard.as_slice()
&& let Some(target) = super::anim::path_target(s, p)?
{
let path = path.clone();
return super::anim::paste_path(s, p, target, &path);
}
if !s.state.value_clipboard.is_empty() {
return paste_properties(s, p);
}
@@ -808,8 +822,30 @@ fn original_path(s: &Session) -> Option<String> {
}
}
/// Whether Edit Original may hand `path` to the system's default app: a regular file (after
/// following links) whose name, and the name it links to, have a media extension the footage
/// importer takes. Anything else (a folder or app bundle, a script, a shortcut) is refused.
pub(crate) fn openable_original(path: &str) -> std::result::Result<(), String> {
let media = |p: &std::path::Path| {
let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
crate::media_browser::kind_of(&p.to_string_lossy()).is_some_and(|k| k != "project") || matches!(ext.as_str(), "pdf" | "ai" | "eps")
};
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
let real = std::fs::canonicalize(path).map_err(|e| format!("cannot open “{name}”: {e}"))?;
if !real.is_file() {
return Err(format!("“{name}” is not a file"));
}
if !media(std::path::Path::new(path)) || !media(&real) {
return Err(format!("Edit Original opens media files only; “{name}” is not one"));
}
Ok(())
}
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
let stored = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
let path = s.stored_path(&stored).map_err(|why| super::bad("edit.editOriginal", crate::doc_path::refusal(&stored, why)))?;
let path = path.to_string_lossy();
openable_original(&path).map_err(|e| super::bad("edit.editOriginal", e))?;
let url = super::layer_menu::file_url(&path);
s.events.push(crate::Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
@@ -952,20 +988,67 @@ fn always_ok(_: &Session) -> std::result::Result<(), String> {
mod tests {
use serde_json::json;
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains.
#[test]
fn edit_original_opens_an_encoded_file_url() {
let clip = tmp("edit-original-url").join("take #1.mov");
std::fs::write(&clip, b"").unwrap();
let clip = clip.to_string_lossy().into_owned();
let url = super::super::layer_menu::file_url(&clip);
assert!(url.ends_with("/take%20%231.mov"), "{url}");
let mut s = crate::Session::default();
let id = s
.edit("add", None, |p, _| {
let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() };
let f = effectcraft_project::Footage { path: clip.clone(), ..Default::default() };
Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
})
.unwrap();
s.state.project_selection = vec![id];
s.drain_events();
let r = s.execute("edit.editOriginal", json!({})).unwrap();
assert_eq!(r["url"], "file:///clips/take%20%231.mov");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov")));
assert_eq!(r["url"], url.as_str());
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == url)));
}
/// Only regular media files reach the system opener.
#[test]
fn edit_original_opens_media_files_only() {
let d = tmp("edit-original-kinds");
for f in ["clip.mov", "Still.PNG", "run.bat", "app.desktop", "clip.mov.lnk"] {
std::fs::write(d.join(f), b"").unwrap();
}
for f in ["Tool.app", "folder.mov"] {
std::fs::create_dir_all(d.join(f)).unwrap();
}
let ok = |f: &str| super::openable_original(&d.join(f).to_string_lossy()).is_ok();
assert!(ok("clip.mov") && ok("Still.PNG"));
for f in ["run.bat", "app.desktop", "clip.mov.lnk", "Tool.app", "folder.mov", "missing.mov", ""] {
assert!(!ok(f), "{f}");
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("app.desktop"), d.join("link.png")).unwrap();
assert!(!ok("link.png"), "a media name linking to another kind of file");
}
// The command refuses without opening anything.
let mut s = crate::Session::default();
let path = d.join("run.bat").to_string_lossy().into_owned();
let id = s
.edit("add", None, |p, _| {
let f = effectcraft_project::Footage { path, ..Default::default() };
Ok(p.add_item("bat", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
})
.unwrap();
s.state.project_selection = vec![id];
s.drain_events();
assert!(s.execute("edit.editOriginal", json!({})).is_err());
assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(_))));
}
}
+17 -2
View File
@@ -891,6 +891,22 @@ pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(
Ok((manifest, project, entries))
}
/// Where a template's `media/<item>/<file>` entry is written: `<media_dir>/<item>/<file>`.
/// `None` unless `<item>` is all digits and `<file>` is a plain file name (not empty, `.` or
/// `..`, no separators, and on Windows no drive, stream or device name), so every entry lands
/// in an item folder directly inside `media_dir`.
fn template_media_path(media_dir: &std::path::Path, entry: &str) -> Option<std::path::PathBuf> {
let (item, file) = entry.strip_prefix("media/")?.split_once('/')?;
if item.is_empty() || !item.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if !super::scripts::is_bare_name(file) || (cfg!(windows) && super::scripts::windows_unsafe_name(file)) {
return None;
}
let out = media_dir.join(item).join(file);
(out.parent().and_then(std::path::Path::parent) == Some(media_dir) && out.file_name() == Some(std::ffi::OsStr::new(file))).then_some(out)
}
fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "essential.importTemplate";
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
@@ -905,8 +921,7 @@ fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
let media_dir = dir.join(format!("{stem} Media"));
let mut written = std::collections::HashMap::new();
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
let rel = k.trim_start_matches("media/");
let out = media_dir.join(rel);
let Some(out) = template_media_path(&media_dir, k) else { continue };
if let Some(parent) = out.parent() {
let _ = std::fs::create_dir_all(parent);
}
+21 -24
View File
@@ -18,7 +18,10 @@ fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
if s.prefs.project.use_template && !tpl.is_empty() {
let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?;
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?;
s.replace_project(Project::from_json(&text)?, None);
let mut proj = Project::from_json(&text)?;
crate::doc_path::resolve_footage(&mut proj, Some(&tpl));
s.replace_project(proj, None);
s.stored_paths.add(&s.project, Some(&tpl));
s.update_sentinel();
return Ok(json!({"template": tpl}));
}
@@ -45,8 +48,12 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
let (mut proj, repaired) =
Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
resolve_relative_footage(&mut proj, path, s.services.as_ref());
// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or
// written by hand) is found next to the project file, as After Effects looks for footage
// relative to the project (#652).
let refused = crate::doc_path::resolve_footage(&mut proj, Some(path));
s.replace_project(proj, Some(path.to_string()));
s.stored_paths.add(&s.project, Some(path));
s.restore_view(&effectcraft_project::saved_view(&text));
s.note_project_path(path);
// Values past the limits (a corrupt or hostile file) were brought within them (#788).
@@ -56,6 +63,10 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let more = if more > 0 { format!(" and {more} more") } else { String::new() };
s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}."));
}
if let Some((_, first, why)) = refused.first() {
let n = refused.len();
s.toast(format!("{n} footage item{} marked missing: {}.", if n == 1 { "" } else { "s" }, crate::doc_path::refusal(first, *why)));
}
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
let saved_by = effectcraft_project::saved_by(&text);
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
@@ -72,28 +83,6 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired}))
}
/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written
/// by hand) is found next to the project file, as After Effects looks for footage relative to
/// the project. It used to be looked up from the working directory: the CLI started in the
/// project's folder found it, the desktop app (started from the Finder, Explorer or a launcher,
/// in another folder) showed and rendered nothing (#652). A path that exists from the working
/// directory is kept.
fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn crate::Services) {
use std::path::Path;
let Some(dir) = Path::new(project_path).parent().filter(|d| !d.as_os_str().is_empty()) else { return };
let beside = |f: &str| dir.join(f).to_string_lossy().to_string();
for item in p.items.values_mut() {
let ItemKind::Footage(f) = &mut item.kind else { continue };
if f.path.is_empty() || !Path::new(&f.path).is_relative() || services.exists(&f.path) || !services.exists(&beside(&f.path)) {
continue;
}
f.path = beside(&f.path);
for frame in f.sequence.iter_mut().filter(|q| Path::new(q.as_str()).is_relative()) {
*frame = beside(frame);
}
}
}
/// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
/// wouldn't read back.
@@ -415,6 +404,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
}
}
paths = rest;
// Composition imports already made their sources, so they bypass add_footage's
// probed-footage refresh. Refresh them before any background probe job starts (#1070).
for id in &items {
if let Some(ItemKind::Footage(f)) = s.project.item(ItemId(*id)).map(|it| &it.kind) {
s.forget_decoded(f);
}
}
}
// Numbered stills as image sequences (the Import dialog's "<format> Sequence" and Force
// Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage.
@@ -459,6 +455,7 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result:
// media layer keys frames by path): a fresh import reads it again (#1070).
for f in &found {
s.forget_decoded(&f.footage);
s.stored_paths.allow(&f.footage);
}
s.edit("Import", None, |proj, st| {
let mut selection = made;
+5 -2
View File
@@ -855,12 +855,15 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
// Replacing a missing file relinks the other missing files that moved with it (as After Effects
// does when you locate one).
let moved = match s.project.item(item).map(|it| &it.kind) {
Some(ItemKind::Footage(previous)) if previous.missing || !s.services.exists(&previous.path) => {
Some(ItemKind::Footage(previous)) if previous.missing || !s.stored_paths.exists(s.services.as_ref(), &previous.path) => {
super::relink_moved::moved_with(s, item, &previous.path, &path)
}
_ => vec![],
};
let relinked: Vec<u64> = moved.iter().map(|(id, _)| id.0).collect();
for nf in std::iter::once(&f).chain(moved.iter().map(|(_, nf)| nf)) {
s.stored_paths.allow(nf);
}
s.edit("Replace Footage", None, |proj, _| {
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?;
it.name = name;
@@ -946,7 +949,7 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
Some(ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(),
_ => return Err(bad("file.revealInFinder", "the item is not a file")),
};
let url = super::layer_menu::folder_url(&path);
let url = super::layer_menu::reveal_url(s, &path).map_err(|e| bad("file.revealInFinder", e))?;
s.events.push(crate::Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
}
+3 -2
View File
@@ -140,9 +140,10 @@ fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
/// A unique EXR layer name (no dots: they separate the layer from the channel).
/// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only:
/// the `exr` crate panics on characters it cannot store in a channel name (#1702).
fn exr_name(name: &str, used: &mut Vec<String>) -> String {
let base: String = name.chars().map(|c| if c == '.' || c.is_control() { '_' } else { c }).collect();
let base: String = name.chars().map(|c| if c == '.' || !(c == ' ' || c.is_ascii_graphic()) { '_' } else { c }).collect();
let base = if base.trim().is_empty() { "Layer".to_string() } else { base };
let mut n = base.clone();
let mut k = 2;
+19 -35
View File
@@ -27,40 +27,24 @@ fn read_project(s: &Session, path: &str) -> Result<(Project, Option<String>)> {
Ok((project, effectcraft_project::saved_by(&text)))
}
/// Footage paths relative to the imported project's folder become absolute when the file is
/// there; returns the footage paths that are missing.
fn resolve_footage(s: &Session, project: &mut Project, dir: &std::path::Path) -> Vec<String> {
/// Footage paths stored in the imported project are resolved against its file
/// ([`crate::doc_path::resolve_footage`]): relative ones become paths beside it, and items whose
/// path is not followed are marked missing. Returns the footage paths that are missing, as stored.
fn resolve_footage(s: &Session, project: &mut Project, path: &str) -> Vec<String> {
let stored: std::collections::HashMap<ItemId, String> =
project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((i.id, f.path.clone())) } else { None }).collect();
let refused = crate::doc_path::resolve_footage(project, Some(path));
let services = s.services.clone();
let resolve = |raw: &mut String| -> bool {
if raw.is_empty() || services.exists(raw) {
return true;
}
if std::path::Path::new(raw.as_str()).is_absolute() {
return false;
}
let joined = dir.join(raw.as_str()).to_string_lossy().to_string();
if services.exists(&joined) {
*raw = joined;
true
} else {
false
}
};
let mut missing = vec![];
for it in project.items.values_mut() {
if let ItemKind::Footage(f) = &mut it.kind {
if f.sequence.is_empty() {
if !resolve(&mut f.path) {
missing.push(f.path.clone());
}
} else {
let mut all = true;
for frame in &mut f.sequence {
all &= resolve(frame);
}
if !all {
missing.push(f.path.clone());
}
let exists = |p: &String| p.is_empty() || services.exists(p);
let mut missing: Vec<String> = refused.iter().map(|(_, p, _)| p.clone()).collect();
for it in project.items.values() {
if let ItemKind::Footage(f) = &it.kind {
if refused.iter().any(|(id, _, _)| *id == it.id) {
continue;
}
let present = if f.sequence.is_empty() { exists(&f.path) } else { f.sequence.iter().all(exists) };
if !present {
missing.push(stored.get(&it.id).cloned().unwrap_or_else(|| f.path.clone()));
}
}
}
@@ -79,8 +63,8 @@ fn import_project(s: &mut Session, p: &Value) -> Result<Value> {
let (mut sub, saved_by) = read_project(s, &path)?;
crate::upgrade_effects(&mut sub);
let file = std::path::Path::new(&path);
let dir = file.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let missing = resolve_footage(s, &mut sub, &dir);
let missing = resolve_footage(s, &mut sub, &path);
s.stored_paths.add(&sub, Some(&path));
// The imported project's own render queue stays behind (as for templates).
let base = s.project.next_id;
if sub.next_id.checked_add(base).is_none() {
+72 -1
View File
@@ -677,6 +677,54 @@ pub(crate) fn folder_url(path: &str) -> String {
file_url(&dir)
}
/// Extensions of folders that macOS opens as an app, plug-in or other bundle rather than showing
/// their contents.
const BUNDLE_EXTENSIONS: &[&str] = &[
"app",
"appex",
"bundle",
"component",
"dext",
"driver",
"framework",
"kext",
"mdimporter",
"mpkg",
"osax",
"pkg",
"plugin",
"prefpane",
"qlgenerator",
"saver",
"service",
"systemextension",
"vst",
"vst3",
"wdgt",
"workflow",
"xpc",
];
/// Whether the system file manager would open folder `dir` as something other than a folder: a
/// bundle (`Tool.app`), or a Windows folder with a `{CLSID}` extension.
fn opens_as_bundle(dir: &std::path::Path) -> bool {
let ext = dir.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
ext.starts_with('{') || BUNDLE_EXTENSIONS.contains(&ext.as_str())
}
/// The folder of the footage file `stored` (a path stored in the project) as a `file://` URL for
/// Reveal in Finder. A path that is not followed ([`crate::doc_path`]), or a folder that is not a
/// plain folder after following links, is refused.
pub(crate) fn reveal_url(s: &Session, stored: &str) -> std::result::Result<String, String> {
let path = s.stored_path(stored).map_err(|why| crate::doc_path::refusal(stored, why))?;
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| format!("{stored} is not in a folder"))?;
let real = std::fs::canonicalize(dir).map_err(|e| format!("cannot show the folder of {stored}: {e}"))?;
if !real.is_dir() || opens_as_bundle(dir) || opens_as_bundle(&real) {
return Err(format!("the folder of {stored} is not a plain folder"));
}
Ok(file_url(&dir.to_string_lossy()))
}
/// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash
/// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces,
/// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file.
@@ -700,7 +748,7 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
Some(effectcraft_project::ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(),
_ => return Err(bad("layer.revealInFinder", "the layer's source is not a file")),
};
let url = folder_url(&path);
let url = reveal_url(s, &path).map_err(|e| bad("layer.revealInFinder", e))?;
s.events.push(crate::Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
}
@@ -892,4 +940,27 @@ mod tests {
fn folder_url_names_the_containing_folder() {
assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips");
}
/// Reveal in Finder shows plain folders only: a bundle the system would open as an app, or a
/// link to one, is refused.
#[test]
fn reveal_refuses_folders_that_open_as_apps() {
let d = std::env::temp_dir().join(format!("ec-reveal-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for f in ["Clips", "Tool.app", "Shell.{20D04FE0-3AEA-1069-A2D8-08002B30309D}"] {
std::fs::create_dir_all(d.join(f)).unwrap();
}
let s = crate::Session::default();
let reveal = |folder: &str| super::reveal_url(&s, &d.join(folder).join("a.mov").to_string_lossy());
assert_eq!(reveal("Clips"), Ok(super::file_url(&d.join("Clips").to_string_lossy())));
assert!(reveal("Tool.app").is_err());
assert!(reveal("Shell.{20D04FE0-3AEA-1069-A2D8-08002B30309D}").is_err());
assert!(reveal("Gone").is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("Tool.app"), d.join("Footage")).unwrap();
assert!(reveal("Footage").is_err(), "a link to a bundle");
}
let _ = std::fs::remove_dir_all(&d);
}
}
+18
View File
@@ -442,6 +442,24 @@ pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_projec
None
}
/// Turn `effects/<match name>[#n]/<property>` (as [`match_path_of`] writes it) into a uid path on
/// `l`. Path parsing splits on `.`, which would cut a match name like `ec.control.slider` apart.
/// Any other path, or an effect that is not on the layer, comes back unchanged.
pub(crate) fn resolve_effect_path(l: &Layer, path: &str) -> String {
let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
let (match_id, nth) = match seg.rsplit_once('#') {
Some((m, n)) if !m.is_empty() => match n.parse::<usize>() {
Ok(n) if n >= 1 => (m, n),
_ => (seg, 1),
},
_ => (seg, 1),
};
match l.effects().and_then(|fx| fx.groups().filter(|g| g.match_id == match_id).nth(nth - 1)) {
Some(g) => format!("effects/@{}/{tail}", g.uid),
None => path.to_string(),
}
}
/// Selected properties (leaves) of the active comp as (layer, uid); selected groups expand to
/// their properties.
pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_project::Uid)> {
+81 -5
View File
@@ -108,13 +108,89 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"rotoBezier": on}))
}
/// The parametric shape paths that Convert To Bezier Path replaces with an editable path.
const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"];
/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the
/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted
/// path is the shape that was on screen rather than an approximation of it; the defaults match
/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness).
fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePath> {
let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64());
let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2());
let path = match g.match_id.as_str() {
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
"star" => effectcraft_path::polystar(
g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
f("points", 5.0),
v2("position", [0.0; 2]),
f("rotation", 0.0),
f("innerRadius", 50.0),
f("outerRadius", 100.0),
f("innerRoundness", 0.0),
f("outerRoundness", 0.0),
),
_ => return None,
};
// A closed parametric shape is one subpath; anything else would not be this shape.
effectcraft_path::from_kurbo(&path).into_iter().next()
}
fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
*roto_bezier = false;
// On a shape layer this replaces a Rectangle, Ellipse or Polystar path with an editable
// Path 1 of the same geometry, as After Effects does (#932). The parametric properties are
// read at the current time and the path that comes out is static: a size or roundness
// animation cannot survive the conversion, in After Effects either.
let (cid, items) = selected_shape_items(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let shapes: Vec<(LayerId, Uid)> = items
.into_iter()
.filter(|(l, u)| {
comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| PARAMETRIC.contains(&g.match_id.as_str()))
})
.collect();
if shapes.is_empty() {
return edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
*roto_bezier = false;
}
Ok(())
});
}
let t = s.time();
let n = shapes.len();
let made = s.edit("Convert To Bezier Path", None, |proj, st| {
let mut next = proj.next_id;
let mut made = Vec::new();
for (lid, uid) in &shapes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?;
let Some(old) = contents.find_group(*uid) else { continue };
let Some(path) = parametric_path(old, lt) else { continue };
// Path Direction is the shape's own, not a default: reversing is a separate command.
let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum());
let enabled = old.enabled;
let mut g = build::shape_path(&mut Ids(&mut next), path);
if let Some(pr) = g.prop_mut("direction") {
pr.value = KV::Enum(direction);
}
g.enabled = enabled;
let new_uid = g.uid;
let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
parent.children[at] = Node::Group(g);
made.push((*lid, new_uid));
}
Ok(())
})
proj.next_id = next;
// The new paths are what is selected, so the vertices can be edited straight away.
if !made.is_empty() {
st.selected_props = made.clone();
}
Ok(made)
})?;
Ok(json!({"paths": made.len(), "of": n}))
}
// ---------------------------------------------------------------- mask options
+80 -4
View File
@@ -44,7 +44,8 @@ fn with_prop<T>(
) -> Result<T> {
let t = s.time_of(cid);
let linear = s.prefs.general.default_spatial_linear;
s.edit(label, merge, |proj, _| {
s.edit(label, merge, |proj, state| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
@@ -59,10 +60,61 @@ fn with_prop<T>(
k.spatial_out = [0.0; 3];
}
}
if pr.match_id == "type" {
let removed = sync_polystar_type(l, uid, lt, &mut next)?;
if !removed.is_empty() {
state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop));
state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop));
}
}
proj.next_id = next;
Ok(r)
})
}
/// A static Polygon has no inner controls; switching to Star creates them in schema order
/// with AE's switch defaults (#1685). Animated/expression-driven types need the controls at
/// every time, including when the current value is Polygon.
fn sync_polystar_type(layer: &mut Layer, uid: Uid, time: Tick, next: &mut Uid) -> Result<Vec<Uid>> {
if !matches!(layer.source, effectcraft_project::LayerSource::Shape) {
return Ok(vec![]);
}
let Some(group) =
layer.props.parent_of_mut(uid).filter(|g| g.match_id == "star" && matches!(g.kind, GroupKind::Indexed) && g.get("type").is_some_and(|p| p.uid == uid))
else {
return Ok(vec![]);
};
let Some(kind) = group.get("type") else { return Ok(vec![]) };
let needs_inner = kind.is_animated() || kind.has_expression() || kind.value_at(time) == effectcraft_keyframe::Value::Enum(0);
if !needs_inner {
let mut removed = vec![];
group.children.retain(|node| {
if let effectcraft_project::Node::Prop(p) = node
&& matches!(p.match_id.as_str(), "innerRadius" | "innerRoundness")
{
removed.push(p.uid);
return false;
}
true
});
return Ok(removed);
}
for (inner, name, outer, value, ui) in [
("innerRadius", "Inner Radius", "outerRadius", 50.0, effectcraft_project::ParamUi::Pixels),
("innerRoundness", "Inner Roundness", "outerRoundness", 0.0, effectcraft_project::ParamUi::Percent),
] {
if group.get(inner).is_none() {
let uid = *next;
*next = next.checked_add(1).ok_or_else(|| bad("prop", "property ids exhausted"))?;
let prop = Property::new(uid, inner, name, effectcraft_keyframe::Value::Scalar(value)).with_ui(ui);
let index =
group.children.iter().position(|n| matches!(n, effectcraft_project::Node::Prop(p) if p.match_id == outer)).unwrap_or(group.children.len());
group.children.insert(index, effectcraft_project::Node::Prop(prop));
}
}
Ok(vec![])
}
/// Explicit command key times default to layer time; readers and the CTI use comp time.
#[derive(Clone, Copy)]
pub(crate) enum KeyTimeBase {
@@ -241,7 +293,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
}
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
/// `offset` (a scrub) the same change from each layer's own value. With `offset` and
/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims`
/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers,
/// which keep the rest of their own value (#1434). With `offset` and
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
/// one that a continuing scrub keeps folding into).
@@ -261,6 +315,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
let scalar = p.get("value").is_some_and(Value::is_number);
// Typed into some components only: the others stay each layer's own.
let dims: Option<Vec<usize>> = match (&delta, p.get("dims").and_then(Value::as_array), &new) {
(None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()),
_ => None,
};
let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| {
if let (Some(dims), Some(n)) = (&dims, &new)
&& let Some(mut c) = current_components(s, cid, olid, ouid)
{
for &d in dims {
if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) {
*slot = *v;
}
}
q["value"] = json!(c);
}
};
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
if let Some(d) = &delta
@@ -291,6 +362,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut q = twin_params(p, olid, ouid);
shifted(s, &mut q, olid, ouid, k);
typed(s, &mut q, olid, ouid);
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
let _ = set(s, &q);
}
@@ -967,6 +1039,7 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
let sel = s.state.selected_keys.clone();
s.edit("Delete Keyframes", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut removed = 0usize;
for k in &sel {
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
&& let Some(i) = key_at(&pr.keys, k.time)
@@ -975,10 +1048,13 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
pr.value = pr.keys[0].value.clone();
}
pr.keys.remove(i);
removed += 1;
}
}
st.selected_keys.clear();
Ok(json!(sel.len()))
// The keys actually removed: a selected key that is gone (or listed twice) isn't counted,
// so a caller can tell that a delete changed nothing.
Ok(json!(removed))
})
}
@@ -1229,7 +1305,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Property Value",
[],
None,
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}",
has_layers,
set_selected
),
+46 -5
View File
@@ -100,6 +100,22 @@ pub(crate) fn selected_contents(s: &Session) -> Vec<(LayerId, Uid)> {
out
}
/// Text animators and their selectors selected on the active comp's text layers, top to bottom.
/// A selector inside a selected animator isn't listed, since it is copied with it.
pub(crate) fn selected_animator_items(s: &Session) -> Vec<(LayerId, Uid)> {
let mut out = vec![];
let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return out };
for l in c.layers.iter().filter(|l| matches!(l.source, LayerSource::Text)) {
if let Some(animators) = l.props.group("text/animators") {
let selected = |u: Uid| s.state.selected_props.contains(&(l.id, u));
let mut uids = vec![];
collect_selected(animators, &selected, &mut uids, 0);
out.extend(uids.into_iter().map(|u| (l.id, u)));
}
}
out
}
fn collect_selected(g: &PropGroup, selected: &dyn Fn(Uid) -> bool, out: &mut Vec<Uid>, depth: usize) {
if depth > super::shape_stroke::MAX_DEPTH {
return;
@@ -127,8 +143,9 @@ pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<
}
/// Edit ▸ Paste with shape items on the clipboard: copies go into each selected shape layer's
/// Contents, above its topmost selected item (in that item's group), else on top. Names stay
/// unique in their group ("Rectangle 2"…), and the copies are selected.
/// Contents, above its topmost selected item (in that item's group), else on top (paints and path
/// operations alone: at the bottom). Names stay unique in their group ("Rectangle 2"…), and the
/// copies are selected.
pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.paste";
let clip = s.state.contents_clipboard.clone();
@@ -140,6 +157,9 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(c, "select a shape layer to paste shape items into"));
}
let sel = selected_contents(s);
let acts_on_above = clip.iter().all(|g| !matches!(g.match_id.as_str(), "group" | "rect" | "ellipse" | "star" | "path"));
// Merge Paths clears the draws above it, so at the bottom it would erase the paints (#1681).
let has_merge = clip.iter().any(|g| g.match_id == "merge");
let label = match clip.as_slice() {
[g] => format!("Paste {}", g.name),
_ => "Paste".to_string(),
@@ -154,11 +174,19 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
let parent = l.props.parent_of(*u)?;
Some((parent.uid, parent.children.iter().position(|n| n.uid() == *u)?))
});
let unselected = spot.is_none();
let (parent, at) = match spot {
Some((pu, i)) => (l.props.find_group_mut(pu), i),
None => (l.props.sub_mut("contents"), 0),
};
let parent = parent.ok_or_else(|| bad(c, "the layer has no contents"))?;
// Trim Paths, Wiggle Paths, fills… act on the items above them (as Add places them),
// so with nothing selected they go to the bottom: on top they'd affect nothing (#1681).
let at = match unselected {
true if has_merge => merge_spot(parent),
true if acts_on_above => parent.children.len(),
_ => at,
};
for (k, g) in clip.iter().enumerate() {
let mut g = g.clone();
g.reassign_uids(&mut next);
@@ -174,8 +202,21 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
}
/// Edit ▸ Duplicate with shape items selected: each is copied in place, above the original and
/// named as After Effects does ("Rectangle 1" → "Rectangle 2"), and the copies are selected.
/// Where Merge Paths goes in `contents` when nothing is selected: before the first paint, so the
/// paints still draw the merged path (the renderer drops the draws above a Merge Paths), else after
/// the last path item.
fn merge_spot(contents: &PropGroup) -> usize {
let is = |n: &Node, ids: &[&str]| matches!(n, Node::Group(g) if ids.contains(&g.match_id.as_str()));
let paints = ["fill", "stroke", "gfill", "gstroke"];
match contents.children.iter().position(|n| is(n, &paints)) {
Some(i) => i,
None => contents.children.iter().rposition(|n| is(n, &["rect", "ellipse", "star", "path"])).map_or(0, |i| i + 1),
}
}
/// Edit ▸ Duplicate with shape items, text animators or selectors selected: each is copied in
/// place, above the original and named as After Effects does ("Rectangle 1" → "Rectangle 2"),
/// and the copies are selected.
pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
let c = "edit.duplicate";
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
@@ -183,7 +224,7 @@ pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> R
let mut next = proj.next_id;
let mut out = vec![];
for (lid, uid) in items {
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the shape item is gone"))?;
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the group is gone"))?;
let Some((i, Node::Group(g))) = parent.children.iter().enumerate().find(|(_, n)| n.uid() == *uid) else { continue };
let mut g = g.clone();
g.reassign_uids(&mut next);
+7 -2
View File
@@ -81,12 +81,13 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
}
let importer = s.importer.clone().ok_or_else(|| bad(COMMAND, "media import is not available in this build"))?;
let services = s.services.clone();
let stored = s.stored_paths.clone();
let missing: Vec<(ItemId, Footage)> = s
.project
.items
.values()
.filter_map(|item| match &item.kind {
ItemKind::Footage(f) if !f.path.is_empty() && !services.exists(&f.path) => Some((item.id, f.clone())),
ItemKind::Footage(f) if !f.path.is_empty() && !stored.exists(services.as_ref(), &f.path) => Some((item.id, f.clone())),
_ => None,
})
.collect();
@@ -149,7 +150,8 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
// A background search must not overwrite edits made while it was running.
for (id, old, new) in &updates {
if s.project.item(*id).is_none_or(|i| !matches!(&i.kind, ItemKind::Footage(f) if f == old))
|| services.exists(&old.path) || !services.exists(&new.path)
|| stored.exists(services.as_ref(), &old.path)
|| !services.exists(&new.path)
|| (new.kind == FootageKind::Sequence && new.sequence.iter().any(|p| !services.exists(p)))
{
return Err(bad(COMMAND, "footage changed during search; run the search again"));
@@ -157,6 +159,9 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
}
let matched = updates.len();
if !dry_run && !updates.is_empty() {
for (_, _, new) in &updates {
s.stored_paths.allow(new);
}
s.edit("Relink Missing Footage", None, |project, _| {
for (id, _, new) in updates {
if let Some(item) = project.item_mut(id) {
+8 -5
View File
@@ -91,8 +91,8 @@ fn moved_footage(footage: &Footage, f: impl Fn(&str) -> Option<String>) -> Optio
Some(nf)
}
fn present(s: &dyn Services, f: &Footage) -> bool {
!f.path.is_empty() && s.exists(&f.path)
fn present(s: &dyn Services, stored: &crate::doc_path::StoredPaths, f: &Footage) -> bool {
stored.exists(s, &f.path)
}
/// Footage items whose file is gone (flagged by the footage check, or not there now).
@@ -101,7 +101,7 @@ fn missing_items(s: &Session) -> Vec<(ItemId, Footage)> {
.items
.values()
.filter_map(|i| match &i.kind {
ItemKind::Footage(f) if f.kind != FootageKind::Data && !f.path.is_empty() && (f.missing || !present(s.services.as_ref(), f)) => {
ItemKind::Footage(f) if f.kind != FootageKind::Data && !f.path.is_empty() && (f.missing || !present(s.services.as_ref(), &s.stored_paths, f)) => {
Some((i.id, f.clone()))
}
_ => None,
@@ -146,6 +146,9 @@ fn apply(s: &mut Session, label: &str, found: Vec<(ItemId, Footage)>) -> Result<
return Ok(json!({"relinked": [], "stillMissing": missing_items(s).len()}));
}
let ids: Vec<u64> = found.iter().map(|(id, _)| id.0).collect();
for (_, nf) in &found {
s.stored_paths.allow(nf);
}
s.edit(label, None, |proj: &mut Project, _| {
for (id, nf) in found {
if let Some(it) = proj.item_mut(id) {
@@ -175,7 +178,7 @@ pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &s
if id == item {
continue;
}
let same_place = moved_footage(&f, |q| remap(q, &old_root, &new_root)).filter(|nf| present(services.as_ref(), nf));
let same_place = moved_footage(&f, |q| remap(q, &old_root, &new_root)).filter(|nf| present(services.as_ref(), &s.stored_paths, nf));
let same_folder = || {
let dir = new_dir.as_deref()?;
let dir_parts = parts(dir);
@@ -183,7 +186,7 @@ pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &s
let old_dir: Vec<&str> = parts(&f.path).get(..old_dir_len)?.to_vec();
moved_footage(&f, |q| remap(q, &old_dir, dir).or_else(|| Some(join(dir, &[file_name(q)])))).filter(|nf| {
// Only frames in that one folder (a name match, not a guess deeper down).
present(services.as_ref(), nf) && parts(&nf.path).len() == dir_parts.len() + 1
present(services.as_ref(), &s.stored_paths, nf) && parts(&nf.path).len() == dir_parts.len() + 1
})
};
if let Some(nf) = same_place.or_else(same_folder) {
+2 -2
View File
@@ -132,7 +132,7 @@ fn is_script_name(name: &str) -> bool {
}
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
fn is_bare_name(name: &str) -> bool {
pub(crate) fn is_bare_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
}
@@ -140,7 +140,7 @@ fn is_bare_name(name: &str) -> bool {
/// stream), ending in `.` or a space (Windows drops them), or a device name, which names a device
/// in any folder and with any extension (`con.jsx`): `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`,
/// `CONOUT$`, `COM0`–`COM9` and `LPT0`–`LPT9` (also with `¹²³`).
fn windows_unsafe_name(name: &str) -> bool {
pub(crate) fn windows_unsafe_name(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_uppercase();
let port = |p: &str| {
let mut n = stem.strip_prefix(p).unwrap_or_default().chars();
+158
View File
@@ -0,0 +1,158 @@
//! Footage paths stored in a document (the project file, an imported project, the New Project
//! template) reach the file system only through [`resolve`]: a relative path is taken from the
//! document's folder, and only local files, or files on the network share the document itself is
//! on, are followed (see `craft_docpath`).
//!
//! Loading a document resolves its footage paths once ([`resolve_footage`]): followed paths are
//! replaced by the resolved path, and an item with a refused path is marked missing, so the media
//! layer never reads it. The session then remembers which paths came from which document
//! ([`StoredPaths`]); the footage check, the changed-on-disk scan, Reload Footage, Relink, Edit
//! Original and Reveal in Finder follow a path only through it. Paths the user imports or relinks
//! in this session are followed as given.
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub use craft_docpath::Refused;
use effectcraft_project::{Footage, ItemId, ItemKind, Project};
/// Why a stored path is not followed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotFollowed {
/// The path is not a local file, or is not on the project's own share.
Refused(Refused),
/// The path is relative and the project has no folder to take it from.
NoProjectFolder,
}
impl fmt::Display for NotFollowed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Refused(r) => r.fmt(f),
Self::NoProjectFolder => f.write_str("the path is relative and the project has not been saved"),
}
}
}
impl std::error::Error for NotFollowed {}
/// The file to open for `stored`, a footage path or `file:` URL stored in the project saved at
/// `project` (`None` for a project without a file), or why it is not followed. A relative
/// `project` is taken from the working directory, where it was opened from.
pub fn resolve(stored: &str, project: Option<&str>) -> Result<PathBuf, NotFollowed> {
let project = project.filter(|p| !p.is_empty()).map(|p| {
let p = Path::new(p);
std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
});
let path = craft_docpath::resolve(stored, project.as_deref()).map_err(NotFollowed::Refused)?;
// A path that is still relative would be taken from the working directory, which the
// project does not choose.
if path.is_relative() {
return Err(NotFollowed::NoProjectFolder);
}
Ok(path)
}
/// The paths of `f` (its file and every frame of an image sequence) resolved against the document
/// saved at `document`, or why one of them is not followed.
fn footage_paths(f: &Footage, document: Option<&str>) -> Result<(String, Vec<String>), (String, NotFollowed)> {
let one = |q: &String| -> Result<String, (String, NotFollowed)> {
if q.is_empty() {
return Ok(String::new());
}
resolve(q, document).map(|p| p.to_string_lossy().into_owned()).map_err(|why| (q.clone(), why))
};
Ok((one(&f.path)?, f.sequence.iter().map(one).collect::<Result<_, _>>()?))
}
fn paths_of(f: &Footage) -> impl Iterator<Item = &String> {
std::iter::once(&f.path).chain(&f.sequence).filter(|q| !q.is_empty())
}
/// Footage paths that came from a document, with the path of that document (`None`: one without
/// a file). Any other path was chosen in this session and is followed as given.
#[derive(Clone, Debug, Default)]
pub struct StoredPaths(Arc<HashMap<String, Option<String>>>);
impl StoredPaths {
/// Record the footage paths of `p`, loaded from the document at `document`. A path already
/// recorded keeps the document it came from first.
pub(crate) fn add(&mut self, p: &Project, document: Option<&str>) {
let map = Arc::make_mut(&mut self.0);
for item in p.items.values() {
if let ItemKind::Footage(f) = &item.kind {
for q in paths_of(f) {
map.entry(q.clone()).or_insert_with(|| document.map(str::to_string));
}
}
}
}
/// The user chose the files of `f` in this session (import, relink, Replace Footage): follow
/// them as given from now on.
pub(crate) fn allow(&mut self, f: &Footage) {
if paths_of(f).any(|q| self.0.contains_key(q)) {
let map = Arc::make_mut(&mut self.0);
for q in paths_of(f) {
map.remove(q);
}
}
}
/// The file to open for footage path `path`.
pub fn follow(&self, path: &str) -> Result<PathBuf, NotFollowed> {
match self.0.get(path) {
Some(document) => resolve(path, document.as_deref()),
None => Ok(PathBuf::from(path)),
}
}
/// Whether the file at footage path `path` exists. A path that is not followed is not looked
/// for.
pub(crate) fn exists(&self, services: &dyn crate::Services, path: &str) -> bool {
!path.is_empty() && self.follow(path).is_ok_and(|p| p.to_str().is_some_and(|p| services.exists(p)))
}
/// Whether every file of `f` is followed, or the first that is not and why.
pub(crate) fn check(&self, f: &Footage) -> Result<(), (String, NotFollowed)> {
paths_of(f).try_for_each(|q| self.follow(q).map(|_| ()).map_err(|why| (q.clone(), why)))
}
}
/// Resolve the footage paths stored in `p`, the project saved at `project`: followed paths are
/// replaced by the file they resolve to, and an item with a path that is not followed is marked
/// missing. Returns those items with the refused path and why.
pub(crate) fn resolve_footage(p: &mut Project, project: Option<&str>) -> Vec<(ItemId, String, NotFollowed)> {
let mut refused = vec![];
for item in p.items.values_mut() {
let ItemKind::Footage(f) = &mut item.kind else { continue };
match footage_paths(f, project) {
Ok((path, sequence)) => {
f.path = path;
f.sequence = sequence;
}
Err((path, why)) => {
f.missing = true;
refused.push((item.id, path, why));
}
}
}
refused
}
/// The message for footage whose stored path is not followed.
pub(crate) fn refusal(stored: &str, why: NotFollowed) -> String {
format!("the footage file {stored} is not opened: {why}")
}
impl crate::Session {
/// The file to open for footage path `stored` in this session's project.
pub fn stored_path(&self, stored: &str) -> Result<PathBuf, NotFollowed> {
self.stored_paths.follow(stored)
}
}
#[cfg(test)]
mod tests;
+153
View File
@@ -0,0 +1,153 @@
use std::sync::{Arc, Mutex};
use effectcraft_project::{Footage, ItemId, ItemKind};
use serde_json::json;
use crate::{Event, Services, Session};
/// File access that records every path it is asked about.
#[derive(Default)]
struct Recording(Mutex<Vec<String>>);
impl Recording {
fn note(&self, path: &str) {
self.0.lock().unwrap().push(path.to_string());
}
}
impl Services for Recording {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
self.note(path);
std::fs::read(path)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
std::fs::write(path, data)
}
fn exists(&self, path: &str) -> bool {
self.note(path);
std::path::Path::new(path).exists()
}
fn stamp(&self, path: &str) -> Option<(u64, u128)> {
self.note(path);
let meta = std::fs::metadata(path).ok()?;
Some((meta.len(), meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos()))
}
}
/// Probes every file as a still.
struct Stills;
impl crate::Importer for Stills {
fn probe(&self, path: &str) -> Result<Footage, String> {
if !std::path::Path::new(path).is_file() {
return Err(format!("{path}: no such file"));
}
Ok(Footage { path: path.into(), kind: effectcraft_project::FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
}
}
fn footage_id(s: &Session, name: &str) -> ItemId {
s.project.items.values().find(|i| i.name == name).unwrap().id
}
fn footage<'a>(s: &'a Session, name: &str) -> &'a Footage {
match &s.project.item(footage_id(s, name)).unwrap().kind {
ItemKind::Footage(f) => f,
_ => panic!("{name} is not footage"),
}
}
/// Footage paths to other computers stored in a project are never looked at: not on open, by
/// the footage check, by the changed-on-disk scan, by Edit Original or by Reveal in Finder. A
/// relative path is taken from the project's folder, and a file the user imports in this session
/// is used as given until the project is saved and opened again.
#[test]
fn footage_on_other_computers_is_never_looked_at() {
let root = std::env::temp_dir().join(format!("ec-doc-path-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("job");
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(root.join("media")).unwrap();
std::fs::write(dir.join("clip.png"), b"x").unwrap();
std::fs::write(root.join("media").join("far.png"), b"x").unwrap();
let mut stored = vec![("unc", r"\\192.0.2.1\share\x.png".to_string()), ("url", "file://192.0.2.1/s/x.png".to_string()), ("beside", "clip.png".to_string())];
// Linux reads `//tmp/…/far.png` as the local file: a sink that skipped the check would find it.
if cfg!(unix) {
stored.push(("slashes", format!("/{}", root.join("media").join("far.png").display())));
}
let mut s = Session::default();
for (name, path) in &stored {
let f = Footage { path: path.clone(), width: 64, height: 48, has_video: true, ..Default::default() };
Arc::make_mut(&mut s.project).add_item(name, crate::color::Label::None, None, ItemKind::Footage(f));
}
let project = dir.join("p.ecproj");
s.execute("file.saveAs", json!({"path": project.to_string_lossy()})).unwrap();
let rec = Arc::new(Recording::default());
let mut o = Session { services: rec.clone(), ..Default::default() };
o.execute("file.open", json!({"path": project.to_string_lossy()})).unwrap();
for (name, _) in &stored {
assert_eq!(footage(&o, name).missing, *name != "beside", "{name}");
}
assert_eq!(std::path::Path::new(&footage(&o, "beside").path), dir.join("clip.png"));
let r = o.execute("footage.check", json!({"wait": true})).unwrap();
assert_eq!(r["missing"].as_u64(), Some(stored.len() as u64 - 1), "{r}");
o.execute("footage.reloadChanged", json!({})).unwrap();
o.drain_events();
for (name, _) in &stored {
o.state.project_selection = vec![footage_id(&o, name)];
let opened = o.execute("edit.editOriginal", json!({}));
let revealed = o.execute("file.revealInFinder", json!({}));
assert_eq!((opened.is_ok(), revealed.is_ok()), (*name == "beside", *name == "beside"), "{name}: {opened:?} {revealed:?}");
}
let urls: Vec<String> = o.drain_events().into_iter().filter_map(|e| if let Event::OpenUrl(u) = e { Some(u) } else { None }).collect();
assert_eq!(urls.len(), 2, "{urls:?}");
let seen = rec.0.lock().unwrap().clone();
assert!(seen.iter().any(|p| std::path::Path::new(p) == dir.join("clip.png")), "{seen:?}");
// Only files in the temporary folder (the project's folder, where moved footage is looked for).
let local = root.to_string_lossy().into_owned();
for p in &seen {
assert!(p.starts_with(&local), "looked at {p}");
}
// Chosen in this session, the same file is followed as given (as a file on a NAS would be)…
#[cfg(unix)]
{
let far = stored.iter().find(|(n, _)| *n == "slashes").unwrap().1.clone();
o.importer = Some(Arc::new(Stills));
o.execute("file.import", json!({"paths": [far.clone()]})).unwrap();
let imported = |s: &Session| -> Vec<ItemId> {
s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.path == far)).map(|i| i.id).collect()
};
let id = *imported(&o).iter().find(|id| matches!(&o.project.item(**id).unwrap().kind, ItemKind::Footage(f) if !f.missing)).unwrap();
o.state.project_selection = vec![id];
o.execute("edit.editOriginal", json!({})).unwrap();
o.execute("file.revealInFinder", json!({})).unwrap();
assert!(o.execute("footage.check", json!({"wait": true, "items": [id.0]})).unwrap()["missing"] == 0);
// …and is a stored path again once the project is saved and opened.
o.execute("file.save", json!({})).unwrap();
let mut q = Session::default();
q.execute("file.open", json!({"path": project.to_string_lossy()})).unwrap();
let ids = imported(&q);
assert_eq!(ids.len(), 2);
for id in ids {
assert!(matches!(&q.project.item(id).unwrap().kind, ItemKind::Footage(f) if f.missing));
q.state.project_selection = vec![id];
assert!(q.execute("edit.editOriginal", json!({})).is_err());
assert!(q.execute("file.revealInFinder", json!({})).is_err());
}
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn stored_paths_resolve_against_the_project() {
let doc = std::env::temp_dir().join("art").join("intro.ecproj");
let doc = doc.to_str().unwrap();
assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov")));
assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder));
assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network)));
#[cfg(windows)]
assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok());
}
+16 -14
View File
@@ -31,10 +31,6 @@ enum Found {
Moved(Box<Footage>),
}
fn exists(s: &dyn crate::Services, path: &str) -> bool {
!path.is_empty() && s.exists(path)
}
/// Start the check (all footage, or `items`). `wait` runs it to completion first (always on
/// wasm32); the result is then `{checked, missing, probed}`.
pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::Result<Value> {
@@ -54,6 +50,7 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::
let services = s.services.clone();
let importer = s.importer.clone();
let rate = s.sequence_rate();
let stored = s.stored_paths.clone();
let project_dir = s.path.as_deref().and_then(crate::commands::relink_moved::project_dir);
let n = jobs.len();
s.spawn_task("footageCheck", format!("Checking footage ({n} items)"), wait, move |ctl| {
@@ -64,13 +61,17 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::
return Err(crate::render_queue::CANCELLED.into());
}
let f = &j.footage;
let present = if f.sequence.is_empty() {
exists(services.as_ref(), &f.path)
} else {
// A sequence is there while any of its frames is (missing frames render as
// placeholders, as in After Effects).
exists(services.as_ref(), &f.path) || f.sequence.iter().any(|p| exists(services.as_ref(), p))
};
// A path that is not followed is never looked for (the item stays missing).
let followed = stored.check(f).is_ok();
let exists = |p: &str| stored.exists(services.as_ref(), p);
let present = followed
&& if f.sequence.is_empty() {
exists(&f.path)
} else {
// A sequence is there while any of its frames is (missing frames render as
// placeholders, as in After Effects).
exists(&f.path) || f.sequence.iter().any(|p| exists(p))
};
// Moved with the project: the same place under the project's folder.
if !present
&& let Some(dir) = &project_dir
@@ -233,9 +234,10 @@ mod tests {
let mut paths: Vec<String> =
o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect();
paths.sort();
// Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed
// separators; `Path` equality compares components, so both spellings are the same file.
let mut expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
// `Path` equality compares components, so `/` and `\` spell the same file on Windows.
let abs = crate::doc_path::resolve("/nonexistent/ec/abs.png", path.to_str()).unwrap();
let gone = dir.join("media").join("ec-relative-gone.png");
let mut expected = [abs.to_str().unwrap(), clip.to_str().unwrap(), gone.to_str().unwrap()];
expected.sort();
assert_eq!(paths.len(), expected.len(), "{paths:?}");
for (got, want) in paths.iter().zip(expected) {
+6 -3
View File
@@ -32,12 +32,13 @@ pub type Scan = Arc<Mutex<Option<Vec<(String, Option<Stamp>)>>>>;
/// The files footage items read: each item's file and every frame of an image sequence. Image
/// sequences count only with Automatically Reload Footage set to All Footage (After Effects'
/// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up).
/// Items with a stored path that is not followed ([`crate::doc_path`]) are not watched.
fn watched(s: &Session) -> BTreeSet<String> {
let sequences = s.prefs.import.auto_reload_footage == "all";
let mut paths = BTreeSet::new();
for it in s.project.items.values() {
if let ItemKind::Footage(f) = &it.kind {
if !f.sequence.is_empty() && !sequences {
if (!f.sequence.is_empty() && !sequences) || s.stored_paths.check(f).is_err() {
continue;
}
if !f.path.is_empty() {
@@ -136,8 +137,10 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi
let mut failed = BTreeSet::new();
for i in ids {
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
// Probed the way it imports by default, so a sequence is found as one.
let nf = match s.probe_footage(&f.path) {
// Probed the way it imports by default, so a sequence is found as one. A path that
// is not followed is not read.
let probed = s.stored_paths.check(f).map_err(|(p, why)| crate::doc_path::refusal(&p, why)).and_then(|_| s.probe_footage(&f.path));
let nf = match probed {
Ok(p) => refreshed(s, f, p),
Err(_) if undoable => Footage { missing: true, ..f.clone() },
Err(_) => {
+27 -10
View File
@@ -15,6 +15,7 @@ pub mod camera_track;
pub mod commands;
pub mod config;
pub mod demo;
pub mod doc_path;
pub mod footage_check;
pub mod footage_reload;
pub mod guard;
@@ -344,6 +345,11 @@ fn one_view() -> u8 {
1
}
/// `t` as a current time of `comp`: a frame within its duration.
fn comp_frame_at(comp: &Comp, t: Tick) -> Tick {
comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO))
}
/// What Copy with Property Links / Copy Expression Only put on the clipboard.
#[derive(Clone, Debug, PartialEq)]
pub enum LinkClip {
@@ -508,6 +514,8 @@ pub struct Session {
/// event loop (the desktop app) turn this on; headless sessions run `footage.check` when
/// they want it.
pub check_footage_on_open: bool,
/// Footage paths that came from a document, with that document ([`doc_path`]).
pub stored_paths: doc_path::StoredPaths,
/// Each footage file's size and modification time when last seen (Settings ▸ Import ▸
/// Reload Footage Changed on Disk, [`footage_reload`]).
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
@@ -608,6 +616,7 @@ impl Default for Session {
next_task_id: 1,
learn: None,
check_footage_on_open: false,
stored_paths: Default::default(),
footage_stamps: Default::default(),
footage_scan: None,
script_calls: ScriptCalls::default(),
@@ -860,11 +869,7 @@ impl Session {
pub fn set_time(&mut self, t: Tick) {
if let Some(c) = self.state.active_comp {
let comp = self.project.comp(c);
let t = match comp {
Some(comp) => comp.frame_rate.snap_nearest(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())),
None => t,
};
let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t));
self.state.times.insert(c, t);
if self.prefs.general.sync_time_related_items {
self.sync_related_times(c, t);
@@ -983,15 +988,24 @@ impl Session {
self.events.push(Event::OpenComp(id));
}
/// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]).
/// The open compositions and their current times, as a project file records them
/// ([`effectcraft_project::SavedView`]).
pub fn saved_view(&self) -> effectcraft_project::SavedView {
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() }
let comp_times = self.state.times.iter().filter(|(id, _)| self.project.comp(**id).is_some()).map(|(id, t)| (*id, *t)).collect();
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone(), comp_times }
}
/// Reopen the compositions a project file was saved with (#748). Ids that no longer name a
/// comp are skipped; with none left the project stays on the comp [`Session::replace_project`]
/// opened (its first), as files from before the view was saved do.
/// Reopen the compositions a project file was saved with (#748), each at its saved current
/// time (#1714). Ids that no longer name a comp are skipped; with none left the project stays
/// on the comp [`Session::replace_project`] opened (its first), as files from before the view
/// was saved do.
pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) {
for (id, t) in &view.comp_times {
if let Some(comp) = self.project.comp(*id) {
let t = comp_frame_at(comp, *t);
self.state.times.insert(*id, t);
}
}
let mut open: Vec<ItemId> = Vec::new();
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
if !open.contains(&id) {
@@ -1128,6 +1142,7 @@ impl Session {
job.wait();
}
self.render_job = None;
self.stored_paths = Default::default();
self.stop_track();
self.track_job = None;
self.stop_mask_track();
@@ -1227,6 +1242,8 @@ mod tests_codec_options;
#[cfg(test)]
mod tests_color_view;
#[cfg(test)]
mod tests_delete_keys;
#[cfg(test)]
mod tests_disk_cache;
#[cfg(test)]
mod tests_effects;
+1 -1
View File
@@ -306,7 +306,7 @@ fn kind_name(k: FootageKind) -> &'static str {
/// Metadata of footage (the fields the Metadata panel lists).
pub fn footage_metadata(f: &Footage) -> Value {
let (created, modified, size) = file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path));
let (created, modified, size) = if f.missing { (None, None, None) } else { file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path)) };
json!({
"path": f.path,
"kind": kind_name(f.kind),
+4 -2
View File
@@ -310,13 +310,15 @@ pub fn next_id() -> u64 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
/// Footage files a project reads (paths, sequence frames).
/// Footage files a project reads (paths, sequence frames; missing items read none).
pub fn footage_files(p: &Project) -> Vec<String> {
let mut v: Vec<String> = p
.items
.values()
.filter_map(|i| match &i.kind {
ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>()),
ItemKind::Footage(f) if !f.path.is_empty() && !f.missing => {
Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>())
}
_ => None,
})
.flatten()
+27
View File
@@ -428,6 +428,8 @@ pub struct Prefs {
pub recent_presets: Vec<String>,
/// Character panel: recently used font families, newest first.
pub recent_fonts: Vec<String>,
/// Effects & Presets: the starred effects' ids, in the order starred.
pub effects_favorites: Vec<String>,
/// Preview panel: every shortcut's options.
pub preview: crate::preview::PreviewSettings,
/// View ▸ Simulate Output ▸ My Custom RGB.
@@ -468,6 +470,7 @@ impl Default for Prefs {
recent_footage: vec![],
recent_presets: vec![],
recent_fonts: vec![],
effects_favorites: vec![],
preview: crate::preview::PreviewSettings::default(),
custom_rgb: crate::viewer::CustomRgb::default(),
comp_presets: vec![],
@@ -597,6 +600,9 @@ impl Prefs {
pub fn normalize(&mut self) {
self.version = PREFS_VERSION;
self.comp_presets.retain(CompPreset::is_valid);
let mut seen = std::collections::HashSet::new();
self.effects_favorites.retain(|id| !id.is_empty() && seen.insert(id.clone()));
self.effects_favorites.truncate(MAX_EFFECTS_FAVORITES);
if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) {
self.startup.window_graphics = Startup::default().window_graphics;
}
@@ -715,6 +721,7 @@ impl Prefs {
recent_footage: std::mem::take(&mut self.recent_footage),
recent_presets: std::mem::take(&mut self.recent_presets),
recent_fonts: std::mem::take(&mut self.recent_fonts),
effects_favorites: std::mem::take(&mut self.effects_favorites),
preview: std::mem::take(&mut self.preview),
comp_presets: std::mem::take(&mut self.comp_presets),
..d
@@ -916,6 +923,23 @@ impl Prefs {
push_mru(&mut self.recent_fonts, family, 30);
}
/// Is effect `id` starred in Effects & Presets?
pub fn is_effect_favorite(&self, id: &str) -> bool {
self.effects_favorites.iter().any(|f| f == id)
}
/// Star effect `id` in Effects & Presets, or un-star it; true when it is starred now.
pub fn toggle_effect_favorite(&mut self, id: &str) -> bool {
if self.is_effect_favorite(id) {
self.effects_favorites.retain(|f| f != id);
false
} else {
self.effects_favorites.push(id.to_string());
self.effects_favorites.truncate(MAX_EFFECTS_FAVORITES);
self.is_effect_favorite(id)
}
}
/// The recent fonts the Character panel shows (at most Number of Recent Fonts to Display).
pub fn recent_fonts_shown(&self) -> &[String] {
let n = (self.type_.recent_fonts as usize).min(self.recent_fonts.len());
@@ -933,6 +957,9 @@ impl Prefs {
}
}
/// Most effects Effects & Presets keeps starred (a hand-edited settings file can hold more).
const MAX_EFFECTS_FAVORITES: usize = 1000;
fn push_mru(list: &mut Vec<String>, v: &str, max: usize) {
list.retain(|x| x != v);
list.insert(0, v.to_string());
+29 -15
View File
@@ -225,36 +225,50 @@ impl Session {
let prefs = self.autosave_prefs();
let fail = |e: std::io::Error| EngineError::Other(format!("auto-save failed: {e}"));
let plan = autosave::plan_in(self.file_ops(), &prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot).map_err(fail)?;
let path = plan.path.to_string_lossy().into_owned();
if self.autosave.background && !cfg!(target_arch = "wasm32") {
let project = self.project.clone();
let config = self.config.clone();
let job = plan.clone();
let pending = autosave::PendingWrite::spawn(plan.path.clone(), move || {
let json = project.to_json_compact();
match &config {
Some(c) => job.write(c.files(), json.as_bytes()),
None => job.write(&crate::config::StdFiles, json.as_bytes()),
}
})
.map_err(fail)?;
let pending =
autosave::PendingWrite::spawn(plan.path.clone(), plan.slot, self.revision, self.path.clone(), self.script_ui.project_epoch, move || {
let json = project.to_json_compact();
match &config {
Some(c) => job.write(c.files(), json.as_bytes()),
None => job.write(&crate::config::StdFiles, json.as_bytes()),
}
})
.map_err(fail)?;
self.autosave.pending = Some(pending);
} else {
let json = self.project.to_json_compact();
plan.write(self.file_ops(), json.as_bytes()).map_err(fail)?;
self.commit_autosave(path.clone(), plan.slot, self.revision);
}
let (path, slot) = (plan.path, plan.slot);
let p = path.to_string_lossy().to_string();
Ok(path)
}
/// Advance recovery bookkeeping only for a successfully written snapshot.
fn commit_autosave(&mut self, path: String, slot: u32, revision: u64) {
self.autosave.last_slot = Some(slot);
self.autosave.saved_revision = Some(self.revision);
self.autosave.last_path = Some(p.clone());
self.autosave.saved_revision = Some(revision);
self.autosave.last_path = Some(path);
self.update_sentinel();
Ok(p)
}
/// Wait for a background auto-save ([`autosave::AutoSaveState::background`]) to land.
pub fn autosave_wait(&mut self) -> Result<()> {
match self.autosave.pending.take() {
Some(w) => w.join().map_err(|e| EngineError::Other(format!("auto-save failed: {e}"))),
Some(w) => {
w.join().map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
// Open/New can replace the project before note_project_path collects this
// write. Save As changes only its path. Never publish the old snapshot as the
// new project's recovery record, even when that record's final reset fails.
if w.project_path == self.path && w.project_epoch == self.script_ui.project_epoch {
self.commit_autosave(w.path.to_string_lossy().into_owned(), w.slot, w.revision);
}
Ok(())
}
None => Ok(()),
}
}
@@ -278,7 +292,7 @@ impl Session {
return None;
}
self.autosave.last = Some(now);
if !self.is_dirty() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() {
if !self.is_dirty() || self.autosave.pending.is_some() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() {
return None;
}
if self.path.is_none() && self.prefs.auto_save.location != "custom" && self.default_autosave_root().is_none() && self.autosave_folder_override.is_none()
+47
View File
@@ -686,3 +686,50 @@ fn reopening_a_project_restores_its_active_comp() {
o.insert("openComps".into(), open);
}
}
/// Layer times are saved as exact ticks and read back unchanged, and each comp reopens at the
/// current time it was saved at, not at 0, where a layer starting later shows nothing (#1714).
#[test]
fn reopening_a_project_restores_layer_times_and_current_time() {
use effectcraft_project::ItemId;
use effectcraft_time::{FrameRate, Tick};
let dir = std::env::temp_dir().join(format!("ec-test-1714-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("times.ecproj").to_string_lossy().to_string();
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 108, "height": 192, "duration": 60})).unwrap();
s.execute("layer.newSolid", json!({"name": "crown"})).unwrap();
let cid = s.active_comp_id().unwrap();
let comp = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap();
comp.frame_rate = FrameRate::FPS_59_94;
// The layer from the issue: 34f, 315f and 3033f at 59.94 fps.
let l = comp.layers.first_mut().unwrap();
(l.start_time, l.in_point, l.out_point) = (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800));
s.execute("time.set", json!({"time": 6.0})).unwrap();
let t = s.time();
// A second comp keeps its own time.
let b = ItemId(s.execute("comp.new", json!({"name": "B"})).unwrap()["comp"].as_u64().unwrap());
s.execute("time.set", json!({"time": 2.0})).unwrap();
let tb = s.time();
s.execute("comp.open", json!({"comp": "C"})).unwrap();
s.execute("file.saveAs", json!({"path": path})).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path})).unwrap();
let l = s2.project.comp(cid).unwrap().layers.first().unwrap();
assert_eq!((l.start_time, l.in_point, l.out_point), (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800)));
assert_eq!((s2.time(), s2.state.times.get(&b).copied()), (t, Some(tb)));
assert!(l.in_point <= s2.time() && s2.time() < l.out_point, "the layer shows at the reopened time");
assert!(!s2.is_dirty());
// Hand-edited times past the end or before the start land on the last or first frame.
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
file["compTimes"] = json!({cid.0.to_string(): i64::MAX, b.0.to_string(): -5});
std::fs::write(&path, file.to_string()).unwrap();
let mut s3 = Session::default();
s3.execute("file.open", json!({"path": path})).unwrap();
let c = s3.project.comp(cid).unwrap();
assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration()));
assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration()));
assert_eq!(s3.state.times.get(&b).copied(), Some(Tick::ZERO));
}
+45
View File
@@ -0,0 +1,45 @@
//! `keys.delete` reports the keys it removed, not the keys it was asked to remove.
use effectcraft_project::LayerId;
use serde_json::json;
use crate::{KeyRef, Session};
fn setup() -> (Session, u64, u64) {
let mut s = Session::default();
s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
let layer = s.execute_checked("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for t in 0..3 {
s.execute_checked("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap();
}
let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/opacity").unwrap().uid;
(s, layer, prop)
}
fn keys_of(s: &Session, layer: u64, prop: u64) -> Vec<KeyRef> {
let keys = &s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys;
keys.iter().map(|k| KeyRef { layer: LayerId(layer), prop, time: k.time }).collect()
}
#[test]
fn delete_keys_counts_only_the_keys_it_removed() {
let (mut s, layer, prop) = setup();
let keys = keys_of(&s, layer, prop);
assert_eq!(keys.len(), 3);
// Two real keys, one of them listed twice, and a key of a property that does not exist.
let missing = KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[1].time };
s.state.selected_keys = vec![keys[0], keys[2], keys[0], missing];
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
assert_eq!(removed, json!(2));
assert_eq!(keys_of(&s, layer, prop), vec![keys[1]]);
}
#[test]
fn delete_keys_reports_zero_when_no_selected_key_exists() {
let (mut s, layer, prop) = setup();
let keys = keys_of(&s, layer, prop);
s.state.selected_keys = vec![KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[0].time }];
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
assert_eq!(removed, json!(0));
assert_eq!(keys_of(&s, layer, prop), keys);
}
+53
View File
@@ -741,3 +741,56 @@ fn effect_colour_labels_set_clear_undo_and_round_trip() {
let l = old.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().clone();
assert_eq!(l.effects().unwrap().groups().next().unwrap().label, None);
}
/// #1746: a preset saved from an effect property (`effects/ec.control.slider/slider`, `#2` for the second
/// instance) applies back to the same layer instead of landing in `skipped`.
#[test]
fn preset_round_trips_effect_properties_by_match_name() {
let (mut s, a, _) = comp_with_two_solids();
apply(&mut s, a, "Slider Control");
apply(&mut s, a, "Slider Control");
let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value {
KV::Scalar(v) => v,
ref other => panic!("not a scalar: {other:?}"),
};
for (n, value) in [(1, 30.0), (2, 70.0)] {
s.execute("prop.set", json!({"layer": a, "path": format!("effects/#{n}/slider"), "value": value})).unwrap();
}
for n in [1, 2] {
let path = std::env::temp_dir().join(format!("ec-1746-{}-{n}.ecpreset", std::process::id())).to_string_lossy().to_string();
s.execute("prop.select", json!({"layer": a, "path": format!("effects/#{n}/slider")})).unwrap();
s.execute("anim.savePreset", json!({"path": path})).unwrap();
let saved = std::fs::read_to_string(&path).unwrap();
let want = if n == 1 { "effects/ec.control.slider/slider" } else { "effects/ec.control.slider#2/slider" };
assert!(saved.contains(want), "{saved}");
s.execute("prop.set", json!({"layer": a, "path": format!("effects/#{n}/slider"), "value": 0.0})).unwrap();
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [a]})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(r["applied"], 1, "{r}");
assert_eq!(r["skipped"], json!([]), "{r}");
assert_eq!(slider(&s, n - 1), if n == 1 { 30.0 } else { 70.0 });
}
}
/// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path,
/// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect.
#[test]
fn copied_effect_property_pastes_onto_the_same_effect() {
let (mut s, a, b) = comp_with_two_solids();
apply(&mut s, a, "Gaussian Blur");
apply(&mut s, b, "Gaussian Blur");
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/blurriness", "value": 12.5})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "effects/#1/blurriness"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
let r = s.execute("edit.paste", json!({})).unwrap();
assert_eq!(r["values"], 1, "{r}");
assert_eq!(layer(&s, b).props.prop("effects/#1/blurriness").unwrap().value, KV::Scalar(12.5));
s.execute("prop.setExpression", json!({"layer": a, "path": "effects/#1/blurriness", "expression": "time * 10"})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "effects/#1/blurriness"})).unwrap();
s.execute("edit.copyExpressionOnly", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
let r = s.execute("edit.paste", json!({})).unwrap();
assert_eq!(r["expressions"], 1, "{r}");
assert_eq!(layer(&s, b).props.prop("effects/#1/blurriness").unwrap().expr.as_ref().unwrap().text, "time * 10");
}
+49
View File
@@ -274,6 +274,55 @@ fn template_export_import_round_trip_with_overrides() {
assert!(t.execute("essential.importTemplate", json!({"path": dir.join("bad.ectemplate").to_string_lossy()})).is_err());
}
#[test]
fn template_import_writes_media_only_into_its_media_folder() {
let root = out_dir("ectemplate-names");
let dir = root.join("a").join("b");
std::fs::create_dir_all(&dir).unwrap();
let (mut s, card, _solid, _ids) = card();
let src = dir.join("Src.ectemplate");
s.execute("essential.exportTemplate", json!({"comp": card.0, "path": src.to_string_lossy()})).unwrap();
let mut entries = effectcraft_lottie::zip::read_stored(&std::fs::read(&src).unwrap());
std::fs::remove_file(&src).unwrap();
let abs = root.join("abs").join("x").to_string_lossy().replace('\\', "/");
for name in [
"../x",
"media/../../x",
"media/1/../../../x",
&format!("media/{abs}"),
&format!("media/6{abs}"),
"media/C:\\x",
"media/7/C:\\x",
"media/x/clip.png",
"media/8/sub/clip.png",
"media/2/..",
"media/3/",
"media/4/clip.png",
"media/5/clip.png",
] {
entries.push((name.to_string(), b"data".to_vec()));
}
let path = dir.join("T.ectemplate");
std::fs::write(&path, effectcraft_lottie::zip::store(&entries)).unwrap();
s.execute("essential.importTemplate", json!({"path": path.to_string_lossy()})).unwrap();
let media = dir.join("T Media");
fn files(d: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for e in std::fs::read_dir(d).unwrap().map(|e| e.unwrap().path()) {
if e.is_dir() { files(&e, out) } else { out.push(e) }
}
}
let mut found = vec![];
files(&root, &mut found);
let in_item = |f: &std::path::Path| f.parent().and_then(std::path::Path::parent) == Some(media.as_path());
for f in &found {
assert!(f == &path || in_item(f), "written outside an item folder of the media folder: {f:?}");
}
let mut names: Vec<String> = found.iter().filter(|f| in_item(f)).map(|f| f.strip_prefix(&media).unwrap().to_string_lossy().replace('\\', "/")).collect();
names.sort();
assert_eq!(names, ["4/clip.png", "5/clip.png"]);
}
#[test]
fn media_replacement_swaps_footage_per_instance() {
use std::sync::Arc;
+13
View File
@@ -77,6 +77,19 @@ fn proexr_writes_layer_prefixed_channels_and_the_composite() {
assert!(at("R", 16, 16) < 0.5, "multiplied by 50% blue (linear)");
}
#[test]
fn proexr_names_a_non_ascii_layer_with_underscores_instead_of_panicking() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Frame", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"name": "你好", "color": "#ff0000"})).unwrap();
let dir = out_dir("frame-exr-non-ascii");
let path = dir.join("frame.exr").to_string_lossy().to_string();
let r = s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
let names: Vec<&str> = r["channels"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
assert!(names.contains(&"__.R") && names.contains(&"__.A"), "{names:?}");
assert!(std::path::Path::new(&path).exists());
}
/// Save a 32×32 comp holding one opaque solid of `color` (an sRGB-encoded triple) as ProEXR and
/// return the centre pixel of the composite and of the solid's own layer: `[R, G, B]` each.
fn proexr_centre(color: [f64; 3], bit_depth: u64) -> ([f32; 3], [f32; 3]) {
+1 -1
View File
@@ -113,7 +113,7 @@ fn import_project_merges_with_fresh_ids_and_one_undo() {
// A relative footage path resolves next to the imported project; a missing one is reported.
let data = t.project.items.values().find(|i| i.name == "data.json").unwrap();
let ItemKind::Footage(f) = &data.kind else { panic!("footage") };
assert_eq!(std::path::Path::new(&f.path), dir.join("data.json"));
assert_eq!(std::fs::canonicalize(&f.path).unwrap(), std::fs::canonicalize(dir.join("data.json")).unwrap());
assert_eq!(r["missingMedia"], json!(["/offline/gone.mov"]));
// One undo step removes all of it; redo brings it back.
+88
View File
@@ -1445,6 +1445,60 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch()
assert_eq!(vals, vec![10.0, 0.0], "reversed");
}
/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion
/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI,
/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers
/// as they are, so the source layer's and the target parent's transforms don't move them.
#[test]
fn a_copied_path_pastes_onto_position_as_a_motion_path() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
// A moved and scaled source: converting to comp space would give 2v + (200, 100).
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap();
let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]);
s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap();
// B's parent would shift a converted Position by (0, −50).
let parent = solid(&mut s, "#0000ff");
s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap();
let b = solid(&mut s, "#00ff00");
s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap();
let motion = |s: &Session, id: u64| {
let l = layer(s, id);
let pr = l.props.prop("transform/position").unwrap().clone();
pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::<Vec<_>>()
};
let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| {
let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]];
assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}");
for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() {
assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}");
assert_eq!(*roving, i != 0 && i != 4, "key {i} roving");
assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}");
}
assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI");
assert!(keys.windows(2).all(|w| w[0].0 < w[1].0));
assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent");
};
// A Mask Path keyframe, pasted at 1 s.
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
check(motion(&s, b), 1.0);
// The same path as an unanimated mask, pasted at 5 s onto an unparented layer.
s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let c = solid(&mut s, "#ffffff");
s.execute("time.set", json!({"time": 5.0})).unwrap();
s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
check(motion(&s, c), 5.0);
}
#[test]
fn j_and_k_stop_at_keys_markers_and_the_work_area() {
let mut s = comp();
@@ -1541,6 +1595,40 @@ fn shape_contents_copy_cut_and_paste_between_shape_layers() {
assert!(s.execute("edit.paste", json!({})).is_err());
}
/// Pasting Trim Paths into another shape layer with nothing selected puts it below the shapes,
/// where it acts on them (it acts on the items above it); on top it would trim nothing (#1681, #1629).
#[test]
fn pasted_path_operator_goes_below_the_shapes_it_acts_on() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
let trim = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "trim"})).unwrap()["uid"].as_u64().unwrap();
let b = s.execute("layer.newShape", json!({"kind": "star", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": a, "prop": trim})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(contents_names(&s, b), ["Polystar 1", "Trim Paths 1"]);
}
/// Pasting Merge Paths onto a painted layer with nothing selected keeps the paint: the renderer
/// drops the draws above a Merge Paths, so it goes between the path and the paints (#1681).
#[test]
fn pasted_merge_paths_keeps_the_targets_paint() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "none", "name": "A"})).unwrap()["layer"].as_u64().unwrap();
let merge = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "merge"})).unwrap()["uid"].as_u64().unwrap();
let b = s.execute("layer.newShape", json!({"kind": "none", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "rect"})).unwrap();
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "fill"})).unwrap();
let before = opaque_centroid(&s).2;
s.execute("prop.select", json!({"layer": a, "prop": merge})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(contents_names(&s, b), ["Rectangle Path 1", "Merge Paths 1", "Fill 1"]);
assert_eq!(opaque_centroid(&s).2, before);
}
/// Edit ▸ Duplicate with shape items selected duplicates them in their layer, above the
/// original and named "Rectangle 2", not the layer (#227).
#[test]
+238
View File
@@ -212,6 +212,34 @@ fn prefs_commands_get_set_reset_and_persist() {
);
}
/// Effects & Presets' starred effects live in the settings (#1692): they come back in the next
/// session, un-starring sticks too, Reset All keeps them, and a hand-edited list loses its
/// repeats and empty ids on load.
#[test]
fn effects_favorites_persist_across_sessions() {
let store = Arc::new(MemoryConfig::default());
let mut s = Session { config: Some(store.clone()), ..Default::default() };
assert!(s.prefs.toggle_effect_favorite("ADBE Gaussian Blur 2"));
assert!(s.prefs.toggle_effect_favorite("ADBE Glo2"));
s.save_prefs();
let reopen = |store: &Arc<MemoryConfig>| {
let mut s = Session { config: Some(store.clone()), ..Default::default() };
s.load_settings();
s
};
let mut s2 = reopen(&store);
assert_eq!(s2.prefs.effects_favorites, ["ADBE Gaussian Blur 2", "ADBE Glo2"]);
assert!(!s2.prefs.toggle_effect_favorite("ADBE Glo2"), "un-starred");
s2.save_prefs();
let mut s3 = reopen(&store);
assert_eq!(s3.prefs.effects_favorites, ["ADBE Gaussian Blur 2"]);
s3.execute("prefs.reset", json!({})).unwrap();
assert!(s3.prefs.is_effect_favorite("ADBE Gaussian Blur 2"), "Reset All keeps the stars");
let edited = store.read(PREFS_FILE).unwrap().replace("\"effectsFavorites\": [", "\"effectsFavorites\": [\"\", \"ADBE Glo2\", \"ADBE Glo2\", ");
store.write(PREFS_FILE, &edited).unwrap();
assert_eq!(reopen(&store).prefs.effects_favorites, ["ADBE Glo2", "ADBE Gaussian Blur 2"]);
}
/// Composition Settings presets the user saves live in the settings: they persist, a save with
/// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited
/// settings file) are dropped on load.
@@ -608,6 +636,216 @@ fn atomic_write_does_not_follow_a_link_at_the_old_temp_name() {
let _ = std::fs::remove_dir_all(d);
}
struct AutosaveTestStore {
config: DirConfig,
fail_next: std::sync::atomic::AtomicBool,
gate: std::sync::Mutex<Option<(std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>)>>,
release_on_prefs: std::sync::Mutex<Option<std::sync::mpsc::Sender<()>>>,
fail_reset_for: std::sync::Mutex<Option<String>>,
failed_resets: std::sync::atomic::AtomicUsize,
}
impl ConfigStore for AutosaveTestStore {
fn read(&self, name: &str) -> Option<String> {
self.config.read(name)
}
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
// note_project_path saves preferences after the project/path switch, before joining
// the pending autosave. Release that writer at this exact transition, without sleeps.
if name == PREFS_FILE
&& let Some(release) = self.release_on_prefs.lock().unwrap().take()
{
release.send(()).unwrap();
}
if name == autosave::SENTINEL
&& let Some(path) = self.fail_reset_for.lock().unwrap().as_ref()
{
let record: autosave::Sentinel = serde_json::from_str(data).unwrap();
if record.project.as_ref() == Some(path) && record.autosave.is_none() {
self.failed_resets.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
return Err(std::io::Error::other("injected final recovery reset failure"));
}
}
self.config.write(name, data)
}
fn remove(&self, name: &str) -> std::io::Result<()> {
self.config.remove(name)
}
fn dir(&self) -> Option<PathBuf> {
self.config.dir()
}
fn files(&self) -> &dyn crate::config::FileOps {
self
}
}
impl crate::config::FileOps for AutosaveTestStore {
fn list(&self, dir: &std::path::Path) -> Vec<(String, std::time::SystemTime)> {
crate::config::StdFiles.list(dir)
}
fn write(&self, path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
if let Some((started, release)) = self.gate.lock().unwrap().take() {
started.send(()).unwrap();
release.recv().unwrap();
}
if self.fail_next.swap(false, std::sync::atomic::Ordering::SeqCst) {
return Err(std::io::Error::other("injected autosave write failure"));
}
crate::config::StdFiles.write(path, data)
}
fn remove(&self, path: &std::path::Path) -> std::io::Result<()> {
crate::config::StdFiles.remove(path)
}
fn is_file(&self, path: &std::path::Path) -> bool {
crate::config::StdFiles.is_file(path)
}
}
fn background_autosave_session(dir: &std::path::Path) -> (Session, Arc<AutosaveTestStore>) {
let store = Arc::new(AutosaveTestStore {
config: DirConfig::new(dir.join("config")),
fail_next: Default::default(),
gate: Default::default(),
release_on_prefs: Default::default(),
fail_reset_for: Default::default(),
failed_resets: Default::default(),
});
let mut s = session_with_comp();
s.config = Some(store.clone());
s.prefs.auto_save.enabled = true;
s.prefs.auto_save.interval_minutes = 1;
s.prefs.auto_save.location = "custom".into();
s.prefs.auto_save.folder = dir.join("saves").to_string_lossy().into_owned();
s.autosave.background = true;
s.begin_recovery();
(s, store)
}
#[test]
fn background_autosave_failure_retries_the_same_revision_and_retains_recovery() {
let dir = tmp("autosave-retry");
let (mut s, store) = background_autosave_session(&dir);
let first = s.autosave_now().unwrap();
s.autosave_wait().unwrap();
let committed = s.autosave.saved_revision;
s.execute("layer.newNull", json!({"name": "work to recover"})).unwrap();
let revision = s.revision;
store.fail_next.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(s.autosave_tick(1000.0).is_none());
let failed_path = s.autosave_tick(1061.0).unwrap().unwrap();
assert!(s.autosave_wait().unwrap_err().to_string().contains("injected autosave write failure"));
assert_eq!(s.autosave.saved_revision, committed);
assert_eq!(s.autosave.last_slot, Some(1));
assert_eq!(s.autosave.last_path.as_deref(), Some(first.as_str()));
let sentinel: autosave::Sentinel = serde_json::from_str(&store.read(autosave::SENTINEL).unwrap()).unwrap();
assert_eq!(sentinel.autosave.as_deref(), Some(first.as_str()));
assert!(!std::path::Path::new(&failed_path).is_file());
let retried = s.autosave_tick(1122.0).expect("retry without another edit").unwrap();
assert_eq!(retried, failed_path, "failed writes do not rotate the slot");
s.autosave_wait().unwrap();
assert_eq!(s.revision, revision);
assert_eq!(s.autosave.saved_revision, Some(revision));
assert_eq!(s.autosave.last_slot, Some(2));
assert!(std::fs::read_to_string(&retried).unwrap().contains("work to recover"));
let sentinel: autosave::Sentinel = serde_json::from_str(&store.read(autosave::SENTINEL).unwrap()).unwrap();
assert_eq!(sentinel.autosave.as_deref(), Some(retried.as_str()));
assert!(s.autosave_tick(1183.0).is_none(), "committed revision needs no duplicate write");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn background_autosave_commits_only_the_snapshot_that_finished() {
let dir = tmp("autosave-pending");
let (mut s, store) = background_autosave_session(&dir);
let (started, started_rx) = std::sync::mpsc::channel();
let (release, release_rx) = std::sync::mpsc::channel();
*store.gate.lock().unwrap() = Some((started, release_rx));
let revision = s.revision;
let path = s.autosave_now().unwrap();
started_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
assert!(s.autosave_tick(1000.0).is_none());
let saved = s.autosave.saved_revision;
let last_path = s.autosave.last_path.clone();
let sentinel: autosave::Sentinel = serde_json::from_str(&store.read(autosave::SENTINEL).unwrap()).unwrap();
s.execute("layer.newNull", json!({"name": "edited during save"})).unwrap();
assert!(s.autosave_tick(1061.0).is_none(), "a pending write must not block the next tick or launch a duplicate");
release.send(()).unwrap();
s.autosave_wait().unwrap();
assert_eq!(saved, None, "an in-flight save is not committed");
assert_eq!(last_path, None);
assert_eq!(sentinel.autosave, None);
assert_eq!(s.autosave.saved_revision, Some(revision), "completion must not mark newer edits as saved");
assert_eq!(s.autosave.last_path.as_deref(), Some(path.as_str()));
assert!(!std::fs::read_to_string(&path).unwrap().contains("edited during save"));
let newer = s.autosave_tick(1122.0).unwrap().unwrap();
s.autosave_wait().unwrap();
assert_eq!(s.autosave.saved_revision, Some(s.revision));
assert!(std::fs::read_to_string(newer).unwrap().contains("edited during save"));
std::fs::remove_dir_all(dir).unwrap();
}
fn autosave_project_transition_with_failed_reset(command: &str, reopen: bool) {
let dir = tmp("autosave-project-transition");
let a = dir.join("A.ecproj").to_string_lossy().into_owned();
let b = if reopen { a.clone() } else { dir.join("B.ecproj").to_string_lossy().into_owned() };
let (mut s, store) = background_autosave_session(&dir);
s.execute("file.saveAs", json!({"path": a})).unwrap();
if !reopen {
let mut other = session_with_comp();
other.execute("layer.newNull", json!({"name": "project B"})).unwrap();
other.execute("file.saveAs", json!({"path": b})).unwrap();
}
let b_bytes = std::fs::read(&b).unwrap();
s.execute("layer.newNull", json!({"name": "unsaved project A"})).unwrap();
s.update_sentinel();
let (started, started_rx) = std::sync::mpsc::channel();
let (release, release_rx) = std::sync::mpsc::channel();
*store.gate.lock().unwrap() = Some((started, release_rx));
let snapshot = s.autosave_now().unwrap();
started_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
assert!(!s.autosave.pending.as_ref().unwrap().is_finished());
let previous_record = store.read(autosave::SENTINEL).unwrap();
*store.fail_reset_for.lock().unwrap() = Some(b.clone());
*store.release_on_prefs.lock().unwrap() = Some(release);
s.execute(command, json!({"path": b})).unwrap();
assert_eq!(s.path.as_deref(), Some(b.as_str()));
assert_eq!(store.failed_resets.load(std::sync::atomic::Ordering::SeqCst), 1, "the final recovery reset really failed");
assert_eq!(
store.read(autosave::SENTINEL).as_deref(),
Some(previous_record.as_str()),
"completion must not publish A's snapshot under the switched project"
);
assert_eq!(s.autosave.last_path, None);
assert_eq!(s.autosave.saved_revision, None);
if command == "file.open" {
assert_eq!(std::fs::read(&b).unwrap(), b_bytes, "opening B must not alter its file bytes");
}
let prefs = s.prefs.clone();
drop(s); // Simulate an unclean exit, leaving the failed-reset sentinel in place.
let mut restart = Session { config: Some(store), prefs, ..Default::default() };
let recovery = restart.begin_recovery().expect("previous project remains recoverable");
assert_eq!(recovery.project.as_deref(), Some(a.as_str()));
assert_eq!(recovery.autosave.as_deref(), Some(snapshot.as_str()));
assert!(recovery.dirty);
assert!(std::fs::read_to_string(snapshot).unwrap().contains("unsaved project A"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn opening_another_project_during_autosave_keeps_recovery_consistent_if_the_reset_fails() {
autosave_project_transition_with_failed_reset("file.open", false);
}
#[test]
fn reopening_the_same_path_during_autosave_keeps_recovery_consistent_if_the_reset_fails() {
autosave_project_transition_with_failed_reset("file.open", true);
}
#[test]
fn save_as_during_autosave_keeps_recovery_consistent_if_the_reset_fails() {
autosave_project_transition_with_failed_reset("file.saveAs", false);
}
#[test]
fn atomic_write_survives_a_crash_mid_write() {
let d = tmp("atomic");
+107
View File
@@ -476,3 +476,110 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
assert!(!s.is_enabled(id), "{id}");
}
}
/// The first path item in a shape layer's Contents, at any depth: (uid, match_id). A new shape
/// layer nests it in a group ("Rectangle 1" ▸ "Rectangle Path 1"), as After Effects does.
fn first_shape(s: &Session, l: u64) -> (u64, String) {
fn walk(g: &effectcraft_project::PropGroup) -> Option<(u64, String)> {
if ["rect", "ellipse", "star", "path"].contains(&g.match_id.as_str()) {
return Some((g.uid, g.match_id.clone()));
}
g.groups().find_map(walk)
}
walk(layer(s, l).props.sub("contents").unwrap()).unwrap()
}
fn shape_path_of(s: &Session, l: u64, uid: u64) -> effectcraft_keyframe::ShapePath {
let ly = layer(s, l);
let g = ly.props.sub("contents").unwrap().find_group(uid).unwrap();
let _ = &g;
g.get("path").unwrap().value.as_path().unwrap().clone()
}
/// Convert To Bezier Path on a shape layer's parametric path gives an editable Path 1 with the
/// same geometry, as After Effects does (#932). The command only turned RotoBezier off for masks,
/// so a Rectangle, Ellipse or Polystar path could not be converted at all.
#[test]
fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
let convert = |args: serde_json::Value| {
let mut s = comp();
let l = s.execute("layer.newShape", args).unwrap()["layer"].as_u64().unwrap();
let (uid, was) = first_shape(&s, l);
assert_ne!(was, "path", "it starts out parametric");
s.state.selected_props = vec![(LayerId(l), uid)];
let out = s.execute("path.convertToBezier", json!({})).unwrap();
assert_eq!(out["paths"].as_u64(), Some(1));
let (new_uid, now) = first_shape(&s, l);
assert_eq!(now, "path", "it is a bezier path now");
// The new path is selected, so its vertices can be edited straight away.
assert_eq!(s.state.selected_props, vec![(LayerId(l), new_uid)]);
let path = shape_path_of(&s, l, new_uid);
assert!(path.closed, "a closed shape stays closed");
(s, l, was, path)
};
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
// A 120 x 80 rectangle is its four corners, joined by straight edges.
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
// Undo brings the parametric shape back.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(first_shape(&s, l).1, was, "undo restores the parametric shape");
// An ellipse is four cubic arcs: a vertex on each axis, with tangents of 0.5523 x the radius
// (the usual circle approximation). Straight lines here would be a diamond.
let (_, _, _, ell) = convert(json!({"kind": "ellipse", "size": [120, 80]}));
assert_eq!(ell.vertices, vec![[0.0, -40.0], [60.0, 0.0], [0.0, 40.0], [-60.0, 0.0]]);
let kappa = 4.0 / 3.0 * (std::f64::consts::SQRT_2 - 1.0);
for (i, want) in [60.0 * kappa, 40.0 * kappa, 60.0 * kappa, 40.0 * kappa].into_iter().enumerate() {
let t = ell.in_tangents[i];
assert!((radius(t) - want).abs() < 1e-6, "vertex {i}: tangent {t:?} should be {want}");
}
// A five-point star alternates the outer and inner radius, so ten vertices.
let (_, _, _, star) = convert(json!({"kind": "star", "size": [120, 120]}));
assert_eq!(star.vertices.len(), 10);
for (i, v) in star.vertices.iter().enumerate() {
let want = if i % 2 == 0 { 60.0 } else { 30.0 };
assert!((radius(*v) - want).abs() < 1e-6, "vertex {i} at {v:?} should be {want} from the centre");
}
// A polygon has one vertex per point, all at the outer radius.
let (_, _, _, poly) = convert(json!({"kind": "polygon", "size": [120, 120]}));
assert!(poly.vertices.len() >= 3);
assert!(poly.vertices.iter().all(|v| (radius(*v) - 60.0).abs() < 1e-6), "{:?}", poly.vertices);
assert_ne!(poly.vertices.len(), star.vertices.len(), "a polygon is not a star");
}
/// The shape's own values are read, not defaults: Roundness rounds the corners and Position moves
/// the path, both at the time the conversion is made.
#[test]
fn convert_to_bezier_reads_the_shape_as_it_is_set() {
let mut s = comp();
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
let (uid, _) = first_shape(&s, l);
// Properties are addressed by their own uid.
let pr_uid = |s: &Session, id: &str| {
let ly = layer(s, l);
ly.props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid
};
let (round, pos) = (pr_uid(&s, "roundness"), pr_uid(&s, "position"));
s.execute("prop.set", json!({"layer": l, "path": format!("@{round}"), "value": 20.0})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": format!("@{pos}"), "value": [10.0, 5.0]})).unwrap();
s.state.selected_props = vec![(LayerId(l), uid)];
s.execute("path.convertToBezier", json!({})).unwrap();
let (new_uid, now) = first_shape(&s, l);
assert_eq!(now, "path");
let path = shape_path_of(&s, l, new_uid);
// Rounded corners need more than four vertices, and curves between them.
assert!(path.vertices.len() > 4, "roundness rounds the corners: {} vertices", path.vertices.len());
assert!(path.in_tangents.iter().chain(&path.out_tangents).any(|t| *t != [0.0, 0.0]), "with curves");
// Position moves the path: the bounds are centred on it, not on the origin.
let xs: Vec<f64> = path.vertices.iter().map(|v| v[0]).collect();
let ys: Vec<f64> = path.vertices.iter().map(|v| v[1]).collect();
let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0;
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
}
+25 -1
View File
@@ -2,7 +2,7 @@
//! undo/redo and serde round-trips).
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Node, Project, PropGroup};
use effectcraft_project::{LayerId, Node, Project, PropGroup};
use serde_json::json;
use crate::Session;
@@ -244,3 +244,27 @@ fn menus_have_text_animation_entries() {
assert!(s.is_enabled("layer.enablePerChar3D"));
assert!(!s.is_enabled("layer.addTextSelector"), "needs an animator");
}
/// Duplicate with a Range Selector selected under a text animator copies the selector, not the
/// whole text layer (#1665); a selected animator is copied the same way.
#[test]
fn duplicate_copies_selected_text_selector_not_the_layer() {
let (mut s, t) = session();
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["tracking"]})).unwrap();
let anims = |s: &Session| s.active_comp().unwrap().layer(LayerId(t)).unwrap().props.group("text/animators").unwrap().clone();
let selectors = |s: &Session| -> Vec<u64> { anims(s).groups().next().unwrap().sub("selectors").unwrap().groups().map(|g| g.uid).collect() };
let sel = selectors(&s)[0];
s.execute("prop.select", json!({"layer": t, "prop": sel, "selectKeys": false})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "no second text layer");
assert_eq!(selectors(&s).len(), 2, "the selector is copied");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(selectors(&s), vec![sel]);
let anim = anims(&s).groups().next().unwrap().uid;
s.execute("prop.select", json!({"layer": t, "prop": anim, "selectKeys": false})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
assert_eq!(anims(&s).groups().count(), 2, "the animator is copied");
}
+60
View File
@@ -317,6 +317,66 @@ fn fonts_filter_variable_families() {
assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable));
}
/// A variable font of `name` and `fvar` only: one `wght` axis 100..900 (default 300, style
/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name
/// `<family without spaces>-<style without spaces>`.
fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
let squash = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).collect::<String>();
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, 10]);
fvar.extend(b"wght".iter().copied().chain(u16s(&[100, 0, 300, 0, 900, 0, 0, 256])));
for (i, (style, w)) in instances.iter().enumerate() {
let id = 256 + 2 * i as u16;
strings.push((id, (*style).into()));
strings.push((id + 1, format!("{}-{}", squash(family), squash(style))));
fvar.extend(u16s(&[id, 0]));
fvar.extend(((w * 65536.0) as i32).to_be_bytes());
fvar.extend(u16s(&[id + 1]));
}
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
let mut off = 0;
for (id, st) in &enc {
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
off += st.len() as u16;
}
enc.iter().for_each(|(_, st)| name.extend(st));
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
let mut body: Vec<u8> = vec![];
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
out.extend(tag);
out.extend([0u8; 4]);
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
}
out.extend(body);
out
}
/// #1563: a variable font's named instances are styles of its family in `text.fonts`, and a
/// text layer set in one (by style, or by the instance's PostScript name as After Effects scripts
/// do) is laid out at the instance's axis values; an axis set on the text still wins.
#[test]
fn variable_font_named_instances_are_styles() {
const FAMILY: &str = "EffectCraft Named Instance Test";
assert_eq!(effectcraft_text::fonts::add_font_data(instanced_variable_font(FAMILY, &[("Light", 300.0), ("Regular", 400.0), ("Black", 900.0)])).len(), 1);
let (mut s, t) = session("Hi");
let fonts = s.execute("text.fonts", json!({"query": FAMILY})).unwrap();
assert_eq!(fonts["families"][0]["styles"], json!(["Light", "Regular", "Black"]), "{fonts}");
let wght = |s: &Session| -> Vec<Vec<(String, f32)>> { effectcraft_text::layout_doc(&doc(s, t)).glyphs.iter().map(|g| g.variations.clone()).collect() };
let black = vec![("wght".to_string(), 900.0)];
s.execute("layer.setText", json!({"layer": t, "font": FAMILY, "style": "Black"})).unwrap();
let by_style = wght(&s);
assert!(!by_style.is_empty() && by_style.iter().all(|v| v == &black), "{by_style:?}");
s.execute("layer.setText", json!({"layer": t, "font": "EffectCraftNamedInstanceTest-Black", "style": "Regular"})).unwrap();
let by_ps = wght(&s);
assert!(!by_ps.is_empty() && by_ps.iter().all(|v| v == &black), "{by_ps:?}");
s.execute("layer.setText", json!({"layer": t, "variations": {"wght": 650}})).unwrap();
assert!(wght(&s).iter().all(|v| v == &[("wght".to_string(), 650.0)]), "{:?}", wght(&s));
}
/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control
/// channel, MCP) accepts them and requires none (#520).
#[test]
+42
View File
@@ -1343,3 +1343,45 @@ fn relative_trim_moves_every_selected_layer_by_the_same_delta() {
s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap();
assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a));
}
/// #1434: with several 3D layers selected, typing a value into one component (Z Position) sets
/// that component on each of them; their other components stay their own. Before, every
/// selected layer took the edited layer's whole value and they piled up on top of it.
#[test]
fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
let (mut s, a) = setup();
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
for (l, xy) in [(a, [10.0, 20.0]), (b, [110.0, 120.0]), (c, [210.0, 220.0])] {
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 5.0]})).unwrap();
}
s.execute("layer.select", json!({"layers": [a, b, c]})).unwrap();
let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value.components();
let uid = prop(&s, a, "transform/position").uid;
// The panels send the edited layer's value with the typed component, and which one it was.
let undo = s.history.undo.len();
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, -300.0], "selected": true, "offset": false, "dims": [2]})).unwrap();
assert_eq!(pos(&s, a), vec![10.0, 20.0, -300.0]);
assert_eq!(pos(&s, b), vec![110.0, 120.0, -300.0], "B keeps its X and Y");
assert_eq!(pos(&s, c), vec![210.0, 220.0, -300.0], "C keeps its X and Y");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(pos(&s, b), vec![110.0, 120.0, 5.0]);
// Linked Scale: both edited components go to every layer, Z stays each layer's own.
s.execute("prop.set", json!({"layer": c, "path": "transform/scale", "value": [50.0, 50.0, 30.0]})).unwrap();
let scale_uid = prop(&s, a, "transform/scale").uid;
s.execute("prop.set", json!({"layer": a, "prop": scale_uid, "value": [200.0, 200.0, 100.0], "selected": true, "dims": [0, 1]})).unwrap();
assert_eq!(prop(&s, c, "transform/scale").value.components(), vec![200.0, 200.0, 30.0]);
// Position with Separate Dimensions on one of the other layers.
s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, 40.0], "selected": true, "dims": [2]})).unwrap();
let axis = |m: &str| prop(&s, b, &format!("transform/{m}")).value.as_f64();
assert_eq!((axis("positionX"), axis("positionY"), axis("positionZ")), (110.0, 120.0, 40.0));
// Without `dims` (agents setting a whole value) every selected layer takes the value as is.
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
}
+60
View File
@@ -637,3 +637,63 @@ fn each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file() {
s.execute_checked("view.res.auto", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
}
/// #1685: changing Type updates the property tree in the same undo step, including when
/// addressed by uid (as in the UI) rather than by path (scripts/control channel).
#[test]
fn polystar_type_switch_updates_inner_controls_and_undoes() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let polygon = group(&s);
let type_uid = polygon.get("type").unwrap().uid;
assert!(polygon.get("innerRadius").is_none());
s.execute("prop.set", json!({"layer": lid, "prop": type_uid, "value": 0})).unwrap();
let star = group(&s);
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(50.0));
assert_eq!(star.get("innerRoundness").unwrap().value, KV::Scalar(0.0));
assert_eq!(star.get("innerRadius").unwrap().ui, effectcraft_project::ParamUi::Pixels);
assert_eq!(star.get("innerRoundness").unwrap().ui, effectcraft_project::ParamUi::Percent);
let names: Vec<_> = star.children.iter().filter_map(|n| n.as_prop()).map(|p| p.match_id.as_str()).collect();
assert_eq!(names, ["direction", "type", "points", "position", "rotation", "innerRadius", "outerRadius", "innerRoundness", "outerRoundness"]);
assert_eq!(star.get("outerRadius"), polygon.get("outerRadius"));
assert!(s.undo());
assert_eq!(group(&s), polygon);
assert!(s.redo());
assert_eq!(group(&s), star);
// Reapplying the same Type must not reset custom inner values or allocate new ids.
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/innerRadius", "value": 37})).unwrap();
let before = group(&s);
let next = s.project.next_id;
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": 0})).unwrap();
assert_eq!(group(&s), before);
assert_eq!(s.project.next_id, next);
let inner_uid = before.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner_uid)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner_uid, time: Tick::ZERO }];
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": 1})).unwrap();
assert_eq!(group(&s).get("type").unwrap().value, KV::Enum(1));
assert!(group(&s).get("innerRadius").is_none());
assert!(group(&s).get("innerRoundness").is_none());
assert!(s.state.selected_props.is_empty());
assert!(s.state.selected_keys.is_empty());
assert!(s.undo());
assert_eq!(group(&s), before, "undo restores the original inner values and ids");
assert!(s.redo());
assert!(group(&s).get("innerRadius").is_none());
s.execute("prop.set", json!({"layer": lid, "prop": type_uid, "value": 0})).unwrap();
assert_eq!(group(&s).get("innerRadius").unwrap().value, KV::Scalar(50.0));
}
#[test]
fn polystar_animated_type_keeps_the_inner_controls_needed_by_other_frames() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/innerRadius", "value": 27})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "time": 1, "value": 1})).unwrap();
let star = layer(&s, lid).props.group("contents/group/contents/star").unwrap().clone();
assert_eq!(star.get("type").unwrap().value_at(Tick::ZERO), KV::Enum(0));
assert_eq!(star.get("type").unwrap().value_at(Tick::from_seconds_f64(1.0)), KV::Enum(1));
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(27.0));
}
+148
View File
@@ -0,0 +1,148 @@
//! Footage whose frame times are rounded to whole milliseconds (#1442), as phones and messaging
//! apps write MP4s: a 30 fps clip's frames last 33 or 34 ms, a 60 fps clip's 16 or 17 ms. It
//! imports at its true rate, a comp made from it (New Comp from Selection takes the footage's
//! rate) renders every source frame once, and the H.264 output has that rate and frame count.
//!
//! The fixtures are written here with FilmCraft's own encoder and muxer: 128×32 frames showing
//! their index in binary (eight bars, white for a set bit).
use std::path::{Path, PathBuf};
use std::sync::Arc;
use effectcraft_color::Label;
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
use effectcraft_export::{Job, export};
use effectcraft_media::MediaPool;
use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build};
use effectcraft_raster::Image;
use effectcraft_time::{FrameRate, Tick};
use filmcraft_isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
const W: u32 = 128;
const H: u32 = 32;
const BARS: u32 = 8;
fn out_dir(name: &str) -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("mkdir");
d
}
/// Durations of `n` frames at `rate` with their times rounded to whole milliseconds.
fn ms_rounded(rate: FrameRate, n: i64) -> Vec<u32> {
let at = |k: i64| (2 * k * 1000 * rate.den + rate.num) / (2 * rate.num);
(0..n).map(|k| u32::try_from(at(k + 1) - at(k)).expect("duration")).collect()
}
/// An H.264 MP4 (video track timescale 1000) with one frame per entry of `durations` (ms);
/// frame k shows k in binary.
fn write_movie(path: &Path, durations: &[u32]) {
let mut cfg = filmcraft_h264enc::EncoderConfig::new(W, H, 30, 1);
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
cfg.aud = false;
cfg.bframes = 0;
cfg.keyint = 30;
cfg.scenecut = false;
let mut enc = filmcraft_h264enc::Encoder::new(cfg).expect("encoder");
let (u, v) = (vec![128u8; (W * H / 4) as usize], vec![128u8; (W * H / 4) as usize]);
let mut packets = Vec::new();
for k in 0..durations.len() {
let y: Vec<u8> = (0..W * H).map(|i| if (k >> (i % W * BARS / W)) & 1 == 1 { 235 } else { 16 }).collect();
let frame = filmcraft_h264enc::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 };
packets.extend(enc.try_encode(&frame, k as i64).expect("encode"));
}
packets.extend(enc.flush());
assert_eq!(packets.len(), durations.len(), "one access unit per frame");
let avcc = AvcConfig::parse(&enc.avcc()).expect("avcC");
let file = std::fs::File::create(path).expect("create");
let mut mux = Mp4Writer::new(file, WriterOptions::new(Brand::Mp4)).expect("mux");
let t = mux.add_track(TrackConfig::new(SampleEntry::avc(avcc, W as u16, H as u16), 1000)).expect("track");
for (p, &duration) in packets.iter().zip(durations) {
mux.write_sample(t, WriteSample { data: &p.data, duration, composition_offset: 0, is_sync: p.keyframe }).expect("sample");
}
mux.finish().expect("finish");
}
/// The frame index a decoded frame shows.
fn shown(img: &Image) -> u32 {
(0..BARS).filter(|b| img.data[img.idx(b * W / BARS + W / BARS / 2, H / 2)][1] > 0.5).map(|b| 1 << b).sum()
}
fn frame_count(f: &Footage) -> i64 {
f.frame_rate.frame_at(f.duration - Tick(1)) + 1
}
/// Every frame of a movie in order, as indices.
fn frames_of(f: &Footage) -> Vec<u32> {
let pool = MediaPool::new();
(0..frame_count(f)).map(|k| shown(&pool.frame_at(f, f.frame_rate.tick_of(k)).expect("decode"))).collect()
}
/// Import `path`, make a comp from it as New Comp from Selection does (the footage's size, rate
/// and duration), render it to H.264 and return the output's footage.
fn comp_from_footage(path: &Path, out: &Path) -> Footage {
let footage = effectcraft_media::probe(path).expect("probe");
let mut p = Project::default();
let fid = p.add_item("clip.mp4", Label::Aqua, None, ItemKind::Footage(footage.clone()));
let rate = p.item(fid).and_then(|it| it.frame_rate()).expect("a movie has a rate");
let mut comp = Comp::new(W, H, rate, footage.duration);
comp.layers.push(build::layer(&mut p, &comp, "clip.mp4", LayerSource::Footage { item: fid }, (W, H), None));
let cid = p.add_item("clip", Label::Sandstone, None, ItemKind::Comp(comp.into()));
let pool = MediaPool::new();
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
let out_path = out.to_string_lossy().to_string();
let job = Job {
project: &p,
footage: &pool,
expr: None,
accel: None,
comp: cid,
settings: &settings,
output: &om,
path: &out_path,
sink: None,
options: Default::default(),
nested_switches: true,
};
let r = export(&job, &mut |_| true).expect("export");
assert_eq!(r.frames as i64, frame_count(&footage), "the render has the footage's frames");
effectcraft_media::probe(out).expect("probe the render")
}
#[test]
fn millisecond_rounded_footage_keeps_its_rate_through_comp_and_render() {
let d = out_dir("ms_rounded");
for (rate, frames) in [(FrameRate::FPS_30, 120), (FrameRate::FPS_60, 240)] {
let path = d.join(format!("ms-{}.mp4", rate.num));
write_movie(&path, &ms_rounded(rate, frames));
let f = effectcraft_media::probe(&path).expect("probe");
assert_eq!((f.frame_rate, frame_count(&f)), (rate, frames), "{rate:?} footage");
let bytes: Arc<[u8]> = std::fs::read(&path).expect("read").into();
let from_bytes = effectcraft_media::probe_bytes(&path.to_string_lossy(), bytes).expect("probe bytes");
assert_eq!(from_bytes.frame_rate, rate, "{rate:?} footage read from memory");
let all: Vec<u32> = (0..frames as u32).collect();
assert_eq!(frames_of(&f), all, "{rate:?} footage: each source frame once");
let out = comp_from_footage(&path, &d.join(format!("render-{}.mp4", rate.num)));
assert_eq!((out.frame_rate, frame_count(&out)), (rate, frames), "{rate:?} render");
assert_eq!(out.duration, Tick::from_seconds_f64(4.0), "{rate:?} render duration");
assert_eq!(frames_of(&out), all, "{rate:?} render: no frame skipped or repeated");
}
}
#[test]
fn ntsc_and_variable_rate_footage_is_not_pulled_to_a_round_rate() {
let d = out_dir("ms_rounded_controls");
for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] {
let path = d.join(format!("ms-{}.mp4", rate.num));
write_movie(&path, &ms_rounded(rate, 60));
assert_eq!(effectcraft_media::probe(&path).expect("probe").frame_rate, rate);
}
// Frames of 40, 45 and 50 ms: the reader's median (45 ms, 22.2 fps) stands.
let path = d.join("variable.mp4");
write_movie(&path, &[40, 45, 50].repeat(20));
let f = effectcraft_media::probe(&path).expect("probe");
assert!(!FrameRate::COMMON.contains(&f.frame_rate) && (f.frame_rate.as_f64() - 1000.0 / 45.0).abs() < 0.01, "{:?}", f.frame_rate);
}
+43 -9
View File
@@ -37,8 +37,12 @@ pub enum Req {
LayerLookup { comp: u64, key: Key },
/// `[name, index, inPoint, outPoint, startTime, width, height, parentId|null, hasVideo, is3D, source, enabled]`.
LayerInfo { comp: u64, layer: u64 },
/// `[path, isGroup, name, matchName, numChildren]` or null.
/// `[path, isGroup, name, matchName, numChildren, propertyIndex]` or null.
Child { comp: u64, layer: u64, group: String, key: Key },
/// `propertyGroup(up)` of the property or group at `path`: the group `up` levels above it as
/// `[path, isGroup, name, matchName, numChildren, propertyIndex]`, `"layer"` when that is the
/// layer itself, or null above the layer.
Ancestor { comp: u64, layer: u64, path: String, up: i64 },
/// `[name, uid, kind, dims, keyTimes, keyValues, hasExpression, propertyIndex, matchName]`.
PropInfo { comp: u64, layer: u64, path: String },
/// Value at comp time `t`; `pre` = keyframes only (no expression).
@@ -256,6 +260,18 @@ fn alias(key: &str) -> &str {
}
}
/// A child node as expression data (see [`Req::Child`]); `index` is 1-based among its siblings.
fn node_resp(path: String, n: &Node, index: usize) -> Resp {
Resp::List(vec![
Resp::Str(path),
Resp::Bool(n.as_group().is_some()),
Resp::Str(n.name().into()),
Resp::Str(n.match_id().into()),
Resp::Num(n.as_group().map_or(0, |g| g.children.len()) as f64),
Resp::Num(index as f64),
])
}
fn find_child<'g>(g: &'g PropGroup, key: &Key) -> Option<&'g Node> {
match key {
Key::Index(i) => usize::try_from(*i).ok().filter(|i| *i >= 1).and_then(|i| g.children.get(i - 1)),
@@ -353,15 +369,33 @@ impl<'a> Resolver<'a> {
let l = self.layer(*comp, *layer)?;
let g = if group.is_empty() { &l.props } else { l.props.group(group)? };
let n = find_child(g, key)?;
let index = g.children.iter().position(|c| c.uid() == n.uid()).unwrap_or(0) + 1;
let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) };
let count = n.as_group().map_or(0, |g| g.children.len());
Resp::List(vec![
Resp::Str(path),
Resp::Bool(n.as_group().is_some()),
Resp::Str(n.name().into()),
Resp::Str(n.match_id().into()),
Resp::Num(count as f64),
])
node_resp(path, n, index)
}
Req::Ancestor { comp, layer, path, up } => {
let l = self.layer(*comp, *layer)?;
// The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes.
let uid = match self.prop(*comp, *layer, path) {
Some((_, p)) => p.uid,
None => l.props.node(path)?.uid(),
};
let full = l.props.path_of(uid)?;
let segs: Vec<&str> = full.split('/').collect();
let up = usize::try_from(*up).ok().filter(|u| *u >= 1 && *u <= segs.len())?;
if up == segs.len() {
return Some(Resp::Str("layer".into()));
}
let keep = segs.len() - up;
let group_path = segs.get(..keep)?.join("/");
let parent = match keep {
1 => &l.props,
_ => l.props.group(&segs.get(..keep - 1)?.join("/"))?,
};
let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::<u64>().ok()?;
let index = parent.children.iter().position(|c| c.uid() == uid)?;
let n = parent.children.get(index)?;
node_resp(group_path, n, index + 1)
}
Req::PropInfo { comp, layer, path } => {
let (l, p) = self.prop(*comp, *layer, path)?;
+16 -1
View File
@@ -18,7 +18,7 @@ var selectorValue = [100, 100, 100];
// Request ops (must match runtime.rs).
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10;
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15;
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15, __ANCESTOR = 16;
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
time = t;
@@ -427,6 +427,18 @@ function __child(c, l, group, key) {
return info[1] ? __mkGroup(c, l, info) : __mkProp(c, l, info[0]);
}
// `propertyGroup(countUp)` of the property or group at `path`: the group `countUp` levels above
// it (default 1), the layer at the top (#1619).
function __ancestor(c, l, path, countUp) {
var n = countUp === undefined ? 1 : __v(countUp);
if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more');
n = Math.floor(n);
var info = __h(__ANCESTOR, c, l, path, n);
if (info === 'layer') return new Layer(c, l);
if (info === null) __err('propertyGroup(' + n + ') goes above the layer');
return __mkGroup(c, l, info);
}
// Comp ------------------------------------------------------------------------------------------
function Comp(c) { this.__c = c; this.__i = null; }
@@ -645,6 +657,8 @@ var __GroupProto = {
get name() { return this.__info[2]; },
get matchName() { return this.__info[3]; },
get numProperties() { return this.__info[4]; },
get propertyIndex() { return this.__info[5]; },
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
get active() { return true; },
toString: function () { return '[object PropertyGroup]'; },
};
@@ -730,6 +744,7 @@ Prop.prototype = {
get numKeys() { return this.__info[4].length; },
get propertyIndex() { return this.__info[7]; },
get matchName() { return this.__info[8]; },
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
__wrap: function (v) {
var kind = this.__info[2];
if (kind === 'layer') return v === null ? null : new Layer(this.__c, v);
+1
View File
@@ -186,6 +186,7 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
},
14 => Req::Project,
15 => Req::SourceInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? },
16 => Req::Ancestor { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, path: string(args, 3, ctx)?, up: num(args, 4, ctx)? as i64 },
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
};
let found = FRAMES.with(|f| {
+2
View File
@@ -1362,3 +1362,5 @@ fn time_to_frames_preserves_coercion_and_small_durations() {
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
}
}
mod property_group;
+3 -1
View File
@@ -78,6 +78,8 @@ const SEEDS: &[&str] = &[
"mask('Mask 1').maskOpacity",
"mask('Mask 1').maskPath.points()[0]",
"content('Rectangle 1').content('Rectangle Path 1').size[0]",
"thisProperty.propertyGroup(1).propertyIndex * 10 + thisProperty.propertyGroup(1).numProperties",
"effect(1)(1).propertyGroup(1).propertyGroup(2).name",
"marker.numKeys > 0 ? marker.key(1).time : 0",
"marker.nearestKey(time).comment",
"thisComp.marker.key(1).time",
@@ -130,7 +132,7 @@ const TOKENS: &[&str] = &[
"catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n",
"/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(",
"content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText",
".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
".style", ".setText(", ".propertyGroup(", ".propertyIndex", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
];
/// Numbers swapped in for numeric literals.
+124
View File
@@ -0,0 +1,124 @@
//! `propertyGroup(countUp)` on properties and property groups, and a group's `propertyIndex`
//! (#1619): name-independent references up the property tree, as in After Effects.
use super::*;
const SCALE_1: &str = "contents/#1/transform/scale";
const SCALE_2: &str = "contents/#2/transform/scale";
/// The test comp with a second shape group "Rectangle 2" on layer "Shape", and "Rectangle 1"'s
/// Transform ▸ Scale keyed 0 → 100 over 0–1 s.
fn staggered() -> Fx {
let mut f = fx();
let g = group_push(&mut f.p, |ids| {
let rect = build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0);
build::shape_group(ids, "Rectangle 2", vec![rect])
});
let shape = f.layer_mut(f.shape);
shape.props.sub_mut("contents").unwrap().children.push(g.into());
shape.props.prop_mut(SCALE_1).unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec2([0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec2([100.0, 100.0]))];
f
}
impl Fx {
fn shape_num(&self, path: &str, text: &str) -> f64 {
match self.ev(self.shape, path, &format!("var r = ({text}); [r, r]"), 0.0) {
Ok(v) => v.as_vec2()[0],
Err(e) => panic!("{text}: {e}"),
}
}
fn shape_err(&self, path: &str, text: &str) -> String {
match self.ev(self.shape, path, text, 0.0) {
Ok(v) => panic!("{text}: expected an error, got {v:?}"),
Err(e) => e,
}
}
}
#[test]
fn shape_group_stagger_from_the_adobe_community_answer() {
let f = staggered();
// thisProperty = Scale, propertyGroup(1) = Transform, propertyGroup(2) = the shape group.
let text = "offset = .1;\nid = thisProperty.propertyGroup(2).propertyIndex;\ncontent(\"Rectangle 1\").transform.scale.valueAtTime(time-id*offset)";
let v = f.ev(f.shape, SCALE_2, text, 0.5).unwrap().as_vec2();
// "Rectangle 2" is the second group in Contents: Rectangle 1's scale 0.2 s earlier.
assert_close!(v[0], 30.0);
assert_close!(v[1], 30.0);
}
#[test]
fn property_group_walks_up_from_the_evaluated_property() {
let f = staggered();
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup().name == 'Transform' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).name == 'Rectangle 2' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).name == 'Contents' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_1, "thisProperty.propertyGroup(2).propertyIndex"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).propertyIndex"), 2.0);
// A shape group holds Blend Mode, Contents and Transform, in After Effects' order.
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyIndex"), 3.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).numProperties"), 3.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).numProperties"), 2.0);
// Groups walk on: Transform → group → Contents.
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(2).name == 'Contents' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(1).propertyIndex"), 2.0);
}
#[test]
fn property_group_reaches_the_layer() {
let f = fx();
assert_close!(f.num("thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0);
assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0);
let s = staggered();
assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0);
}
#[test]
fn property_group_of_an_effect_parameter_is_the_effect() {
let f = fx();
let path = "effects/#1/slider";
let ev = |text: &str| f.ev(f.a, path, text, 0.0).unwrap().as_f64();
assert_close!(ev("thisProperty.propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0);
assert_close!(ev("thisProperty.propertyGroup(2).name == 'Effects' ? 1 : 0"), 1.0);
// The name-independent rig: read the effect this parameter belongs to by its own name.
assert_close!(ev("effect(thisProperty.propertyGroup(1).name)('Slider') + 1"), 43.0);
assert_close!(ev("thisProperty.propertyGroup(1).propertyIndex"), 1.0);
}
#[test]
fn property_group_on_other_layers_properties_and_groups() {
let f = staggered();
// From layer B: another layer's property and group, reached by name.
let ev = |text: &str| f.ev(f.b, ROT, text, 0.0).unwrap().as_f64();
assert_close!(ev("thisComp.layer('A').effect(1)('Slider').propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0);
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').propertyIndex"), 2.0);
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').transform.scale.propertyGroup(2).propertyIndex"), 2.0);
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').content('Fill 1').propertyGroup(2).name == 'Rectangle 1' ? 1 : 0"), 1.0);
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').propertyGroup(2).name == 'Shape' ? 1 : 0"), 1.0);
assert_close!(ev("thisComp.layer('A').effect('Slider Control').propertyIndex"), 1.0);
assert_close!(ev("thisComp.layer('A').mask('Mask 1').propertyGroup(1).numProperties"), 1.0);
}
#[test]
fn property_group_out_of_range_is_an_error_not_a_crash() {
let f = staggered();
for text in [
"thisProperty.propertyGroup(0)",
"thisProperty.propertyGroup(-1)",
"thisProperty.propertyGroup(5)",
"thisProperty.propertyGroup(1e300)",
"thisProperty.propertyGroup(NaN)",
"thisProperty.propertyGroup('x')",
"thisProperty.propertyGroup(1).propertyGroup(4)",
] {
let e = f.shape_err(SCALE_2, text);
assert!(e.contains("propertyGroup"), "{text}: {e}");
}
let e = f.err("thisProperty.propertyGroup(3)");
assert!(e.contains("propertyGroup"), "{e}");
// A fractional count rounds down, as an index does.
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2.7).propertyIndex"), 2.0);
}
+6 -1
View File
@@ -320,7 +320,12 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {
let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]);
globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]);
let mats = s.materials.iter().flat_map(|m| {
let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 | (m.accepts_lights as u32) << 2 | (m.receives_shadows as u32) << 3 | (m.alpha_mode << 8);
let flags = (m.double_sided as u32)
| (m.unlit as u32) << 1
| (m.accepts_lights as u32) << 2
| (m.receives_shadows as u32) << 3
| (m.shadows_only as u32) << 4
| (m.alpha_mode << 8);
[
m.base[0],
m.base[1],
+151 -8
View File
@@ -140,6 +140,74 @@ pub struct TransferStats {
pub readback_bytes: u64,
}
/// `WGPU_ADAPTER_NAME` trimmed; empty or whitespace-only counts as unset (an empty
/// substring would otherwise match the first adapter and skip the power preference).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn wanted_adapter_name(var: Option<&str>) -> Option<&str> {
var.map(str::trim).filter(|w| !w.is_empty())
}
/// Index of the first adapter whose name contains `want`, compared case-insensitively.
/// Only the name is matched, as in wgpu (the backend is chosen with `WGPU_BACKEND`).
/// `Err` lists each distinct adapter once as `name (backend)`, so a typo or an
/// OS/driver-specific name is easy to spot.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn match_adapter_name(want: &str, adapters: &[(String, String)]) -> Result<usize, String> {
if adapters.is_empty() {
return Err(format!("WGPU_ADAPTER_NAME={want:?}: no adapters on the enabled backends"));
}
let w = want.to_lowercase();
adapters.iter().position(|(n, _)| n.to_lowercase().contains(&w)).ok_or_else(|| {
let mut list: Vec<String> = Vec::new();
for l in adapters.iter().map(|(n, b)| format!("{n} ({b})")) {
if !list.contains(&l) {
list.push(l);
}
}
format!(
"no adapter name matches WGPU_ADAPTER_NAME={want:?} (matched against the name only; \
select a backend with WGPU_BACKEND); available: [{}]",
list.join(", ")
)
})
}
/// The adapter named by `WGPU_ADAPTER_NAME` (case-insensitive substring, like wgpu's
/// `initialize_adapter_from_env`, which panics when none matches). `None` when unset or empty.
#[cfg(not(target_arch = "wasm32"))]
async fn adapter_by_name(instance: &wgpu::Instance) -> Option<Result<wgpu::Adapter, String>> {
let var = std::env::var("WGPU_ADAPTER_NAME").ok();
let want = wanted_adapter_name(var.as_deref())?;
let mut all = instance.enumerate_adapters(wgpu::Backends::all()).await;
let adapters: Vec<(String, String)> = all
.iter()
.map(|a| {
let i = a.get_info();
(i.name, format!("{:?}", i.backend))
})
.collect();
Some(match_adapter_name(want, &adapters).map(|i| all.swap_remove(i)))
}
/// `WGPU_POWER_PREF` (`low` / `high` / `none`), default HighPerformance. wgpu ignores an
/// unknown value silently; warn so a typo is noticed.
#[cfg(not(target_arch = "wasm32"))]
fn power_pref_from_env() -> wgpu::PowerPreference {
wgpu::PowerPreference::from_env().unwrap_or_else(|| {
if let Ok(v) = std::env::var("WGPU_POWER_PREF") {
log::warn!("gpu: ignoring WGPU_POWER_PREF={v:?} (expected low, high or none)");
}
wgpu::PowerPreference::HighPerformance
})
}
#[cfg(target_arch = "wasm32")]
fn power_pref_from_env() -> wgpu::PowerPreference {
wgpu::PowerPreference::HighPerformance
}
#[cfg(target_arch = "wasm32")]
async fn adapter_by_name(_: &wgpu::Instance) -> Option<Result<wgpu::Adapter, String>> {
None
}
impl Transfers {
fn count(&self, up: bool, bytes: usize) {
use std::sync::atomic::Ordering::Relaxed;
@@ -774,19 +842,28 @@ impl GpuContext {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
#[cfg(not(target_arch = "wasm32"))]
{
// Match eframe's backend override so CLI and headless acceptance tests
// exercise the backend requested for the native viewer.
desc.backends = wgpu::Backends::from_env().unwrap_or(desc.backends);
// Honour the same wgpu env vars as the viewer (WGPU_BACKEND, WGPU_DX12_COMPILER,
// WGPU_VALIDATION, WGPU_DEBUG, WGPU_POWER_PREF), plus WGPU_ADAPTER_NAME (CLI/headless
// only for now; the viewer ignores it). Not full parity: the viewer also changes
// instance flags and has a GL fallback.
// Note: with_env() also resets memory_budget_thresholds to their default (unused here).
desc = desc.with_env();
}
#[cfg(target_arch = "wasm32")]
{
desc.backends = wgpu::Backends::BROWSER_WEBGPU;
}
let instance = wgpu::Instance::new(desc);
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })
.await
.map_err(|e| format!("no adapter: {e}"))?;
let (adapter, by_name) = match adapter_by_name(&instance).await {
Some(a) => (a?, true),
None => (
instance
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_pref_from_env(), ..Default::default() })
.await
.map_err(|e| format!("no adapter: {e}"))?,
false,
),
};
let limits = adapter.limits();
let required_limits = wgpu::Limits {
max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384),
@@ -796,7 +873,10 @@ impl GpuContext {
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
.await
.map_err(|e| format!("no device: {e}"))?;
.map_err(|e| match by_name {
true => format!("no device on {} (picked by WGPU_ADAPTER_NAME): {e}", adapter.get_info().name),
false => format!("no device: {e}"),
})?;
let context = GpuContext::new(&adapter, device, queue)?;
// This path owns the device; install handlers only after construction succeeds.
crate::readback::install(&context.device, &context.readbacks);
@@ -1392,3 +1472,66 @@ impl<'g> Enc<'g> {
pub(crate) fn mip_levels(w: u32, h: u32) -> u32 {
u32::BITS - w.max(h).max(1).leading_zeros()
}
#[cfg(test)]
mod adapter_name_tests {
use super::{match_adapter_name, wanted_adapter_name};
fn adapters() -> Vec<(String, String)> {
[("NVIDIA GeForce GTX 1070", "Vulkan"), ("Intel(R) UHD Graphics 630", "Dx12"), ("llvmpipe (LLVM 19.1.7, 256 bits)", "Gl")]
.map(|(n, b)| (n.into(), b.into()))
.to_vec()
}
#[test]
fn matches_case_insensitive_substring() {
assert_eq!(match_adapter_name("intel", &adapters()), Ok(1));
assert_eq!(match_adapter_name("GTX 1070", &adapters()), Ok(0));
assert_eq!(match_adapter_name("LLVMPIPE", &adapters()), Ok(2));
}
#[test]
fn first_match_wins() {
assert_eq!(match_adapter_name("i", &adapters()), Ok(0));
}
#[test]
fn empty_or_blank_name_is_unset_and_value_is_trimmed() {
assert_eq!(wanted_adapter_name(None), None);
assert_eq!(wanted_adapter_name(Some("")), None);
assert_eq!(wanted_adapter_name(Some(" ")), None);
assert_eq!(wanted_adapter_name(Some(" intel ")), Some("intel"));
assert_eq!(match_adapter_name(wanted_adapter_name(Some(" intel ")).unwrap(), &adapters()), Ok(1));
}
#[test]
fn mismatch_lists_name_and_backend() {
let e = match_adapter_name("NoSuch", &adapters()).unwrap_err();
assert!(e.contains("\"NoSuch\""), "{e}");
assert!(e.contains("Intel(R) UHD Graphics 630 (Dx12)"), "{e}");
assert!(e.contains("NVIDIA GeForce GTX 1070 (Vulkan)"), "{e}");
}
#[test]
fn backend_is_not_part_of_the_match_and_message_says_so() {
let e = match_adapter_name("dx12", &adapters()).unwrap_err();
assert!(e.contains("name only"), "{e}");
assert!(e.contains("WGPU_BACKEND"), "{e}");
}
#[test]
fn mismatch_lists_identical_name_and_backend_once() {
let mut a = adapters();
a.insert(2, ("Intel(R) UHD Graphics 630".into(), "Dx12".into()));
a.push(("Intel(R) UHD Graphics 630".into(), "Vulkan".into()));
let e = match_adapter_name("NoSuch", &a).unwrap_err();
assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Dx12)").count(), 1, "{e}");
assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Vulkan)").count(), 1, "{e}");
}
#[test]
fn no_adapters_has_its_own_message() {
let e = match_adapter_name("x", &[]).unwrap_err();
assert!(e.contains("no adapters on the enabled backends"), "{e}");
}
}
+4 -1
View File
@@ -111,7 +111,10 @@ fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let dir = ctx.params.f("direction").to_radians();
let speed = ctx.params.f("speed");
let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU;
let (dx, dy) = (dir.sin(), -dir.cos());
// Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave
// boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU.
let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v };
let (dx, dy) = (axis(dir.sin()), -axis(dir.cos()));
if !ctx.adjustment {
b.pad(e, h.abs().ceil() as u32 + 1)?;
}
+1 -1
View File
@@ -493,7 +493,7 @@ fn wire_removal(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let mut p = Params::default();
p.u[0][0] = ctx.params.e("removalStyle");
p.f[0] = [a.0 as f32, a.1 as f32, -ty as f32, tx as f32];
p.f[1] = [half as f32, (half * slope as f64) as f32, mirror, 0.0];
p.f[1] = [half as f32, (half * slope as f64) as f32, mirror, len as f32];
run(e, "fp2_wire", &p, b, None, None)
}
+55 -16
View File
@@ -288,6 +288,53 @@ struct FOut {
@location(1) depth: u32,
};
// shade::to_light: unit direction to the light (xyz) and its distance (w).
fn to_light(kind: u32, lpos: vec3<f32>, ldir: vec3<f32>, pos: vec3<f32>) -> vec4<f32> {
if (kind == 0u) {
return vec4<f32>(-ldir, 1e30);
}
let d = lpos - pos;
let len = sqrt(dot(d, d));
if (len < 1e-6) {
return vec4<f32>(0.0, 0.0, -1.0, 0.0);
}
return vec4<f32>(d / len, len);
}
// shade::shadow_only: the strongest shadow any light casts on the fragment.
fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32>) -> f32 {
var amount = 0.0;
if (unlit) {
return amount;
}
var n = norm3(normal);
if (!front) {
n = -n;
}
let nl_count = u32(g.fwd.w);
for (var li = 0u; li < nl_count; li = li + 1u) {
let lb = li * LIGHT_STRIDE;
let l0 = lights[lb];
let l1 = lights[lb + 1u];
let l2 = lights[lb + 2u];
let l3 = lights[lb + 3u];
let l4 = lights[lb + 4u];
let kind = u32(l0.w);
let tl = to_light(kind, l0.xyz, l1.xyz, pos);
let to_l = tl.xyz;
let dist = tl.w;
if (attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist) <= 0.0) {
continue;
}
let n_l = dot(n, to_l);
if (n_l <= 0.0) {
continue;
}
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n_l));
}
return amount;
}
@fragment
fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
let mb = i.material * MAT_STRIDE;
@@ -302,6 +349,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
let unlit = (flags & 2u) != 0u;
let accepts_lights = (flags & 4u) != 0u;
let receives_shadows = (flags & 8u) != 0u;
let shadows_only = (flags & 16u) != 0u;
let alpha_mode = (flags >> 8u) & 3u;
if (!front && !double_sided) {
discard;
@@ -330,6 +378,10 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
}
var out: FOut;
out.depth = bitcast<u32>(dot(g.view_z.xyz, i.pos) + g.view_z.w);
if (shadows_only) {
out.color = vec4<f32>(0.0, 0.0, 0.0, alpha * shadow_only_amount(unlit || !accepts_lights, i.normal, front, i.pos));
return out;
}
if (unlit || !accepts_lights || g.env2.y < 0.5) {
let c = rgb + emissive;
out.color = vec4<f32>(c * alpha, alpha);
@@ -381,22 +433,9 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
let l3 = lights[lb + 3u];
let l4 = lights[lb + 4u];
let kind = u32(l0.w);
var to_l: vec3<f32>;
var dist: f32;
if (kind == 0u) {
to_l = -l1.xyz;
dist = 1e30;
} else {
let d = l0.xyz - i.pos;
let len = sqrt(dot(d, d));
if (len < 1e-6) {
to_l = vec3<f32>(0.0, 0.0, -1.0);
dist = 0.0;
} else {
to_l = d / len;
dist = len;
}
}
let tl = to_light(kind, l0.xyz, l1.xyz, i.pos);
let to_l = tl.xyz;
let dist = tl.w;
let k = attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist);
if (k <= 0.0) {
continue;
+4 -2
View File
@@ -828,7 +828,7 @@ fn fp2_io_final(@builtin(global_invocation_id) gid: vec3<u32>) {
}
// ---------------------------------------------------------------- CC Simple Wire Removal (keying2::wire_removal)
// u[0].x = style; f[0] = (ax, ay, nx, ny); f[1] = (hw thickness, soft width, mirror).
// u[0].x = style; f[0] = (ax, ay, nx, ny); f[1] = (hw thickness, soft width, mirror, segment length).
fn fp2_wire_pos(base: vec2<f32>, nrm: vec2<f32>, s: f32) -> vec4<f32> {
return sample_bilinear_clamped(src, base.x + nrm.x * s, base.y + nrm.y * s);
@@ -851,7 +851,9 @@ fn fp2_wire(@builtin(global_invocation_id) gid: vec3<u32>) {
let q = vec2<f32>(fx - a.x, fy - a.y);
let d = q.x * nrm.x + q.y * nrm.y;
let ad = abs(d);
if (ad > hw + soft_w) {
// The wire is the segment A to B: the tangent is the normal turned back by 90 degrees.
let along = q.x * nrm.y - q.y * nrm.x;
if (along < 0.0 || along > P.f[1].w || ad > hw + soft_w) {
textureStore(out, p, px);
return;
}
+12
View File
@@ -155,6 +155,18 @@ fn gpu_matches_cpu_unlit_and_full_frames() {
assert!((diff as f64) < 0.02 * cpu.data.len() as f64, "{diff} pixels differ");
}
#[test]
fn gpu_matches_cpu_shadow_catcher() {
// #1691: the floor plane as a shadow catcher ("Accepts Shadows: Only").
let Some(g) = gpu() else { return };
let (mut p, cid) = scene_project();
let floor = p.comp_mut(cid).unwrap().layers.last_mut().unwrap();
set(floor, "materialOptions/acceptsShadows", Value::Enum(2));
let s = scene(&p, cid);
assert!(s.materials.iter().any(|m| m.shadows_only) && !s.shadows.is_empty());
compare(&s, &g);
}
#[test]
fn gpu_raster_perf_smoke() {
let Some(g) = gpu() else { return };
+8
View File
@@ -28,6 +28,11 @@ fn twirl_bulge_ripple_wave_warp() {
effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]);
// #1181: the single-edge pinning paths, with the default vertical displacement direction.
effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(25.0)), ("pinning", e(4))]);
effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(19.0)), ("pinning", e(6)), ("waveType", e(1))]);
effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(4)), ("speed", n(2.0))]);
effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(6)), ("speed", n(2.0))]);
}
#[test]
@@ -79,6 +84,9 @@ fn shape_case(id: &str, vals: &[(&str, Value)]) {
fn edge_pinning_on_a_shape_layer() {
shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]);
shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]);
// #1181: single-edge pinning over the layer rect, not just All Edges.
shape_case("ec.distort.wavewarp", &[("pinning", e(4)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]);
shape_case("ec.distort.wavewarp", &[("pinning", e(6)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]);
shape_case(
"ec.distort.bulge",
&[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))],
+77
View File
@@ -213,3 +213,80 @@ fn a_new_project_importing_an_overwritten_file_shows_the_new_pixels() {
let cid = s.active_comp_id().unwrap();
assert!(near(pixel(&s, cid, 32, 24), BLUE), "stale pixels from the earlier project: {:?}", pixel(&s, cid, 32, 24));
}
fn layered_comp(s: &mut Session, path: &str, import_as: &str) -> ItemId {
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap();
ItemId(r["comps"][0].as_u64().unwrap())
}
/// Composition imports create their sources before probing ordinary footage (#1070).
#[test]
fn fresh_layered_composition_import_refreshes_its_sources() {
for import_as in ["composition", "compositionLayerSizes"] {
let path = tmp(&format!("fresh-{import_as}.psd"));
let (red, blue) = (flat(RED, false), flat(BLUE, false));
assert_eq!(red.len(), blue.len(), "the overwritten file has the same byte size");
std::fs::write(&path, red).unwrap();
let mut s = effectcraft_host::session();
let old_comp = layered_comp(&mut s, &path, import_as);
assert!(near(pixel(&s, old_comp, 32, 24), RED));
s.execute_checked("file.newProject", json!({})).unwrap();
save(&path, &blue);
s.drain_events();
let comp = layered_comp(&mut s, &path, import_as);
assert_eq!(comp, old_comp, "new projects reuse their item ids");
assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview");
assert!(near(pixel(&s, comp, 32, 24), BLUE), "layered import showed {:?}", pixel(&s, comp, 32, 24));
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn layered_reimport_refreshes_existing_layers_and_keeps_unrelated_decoded_files() {
let path = tmp("layered-reimport.psd");
let other = tmp("layered-unchanged.psd");
std::fs::write(&path, flat(RED, false)).unwrap();
std::fs::write(&other, flat(GREEN, false)).unwrap();
let mut s = effectcraft_host::session();
let comp = layered_comp(&mut s, &path, "compositionLayerSizes");
let r = s.execute_checked("file.import", json!({"paths": [&other]})).unwrap();
let other_item = ItemId(r["items"][0].as_u64().unwrap());
let other_footage = footage(&s, other_item);
let cached = s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap();
assert!(near(pixel(&s, comp, 32, 24), RED));
let before = s.project.clone();
let undo = s.history.undo.len();
save(&path, &flat(BLUE, false));
s.drain_events();
layered_comp(&mut s, &path, "compositionLayerSizes");
assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview");
assert!(near(pixel(&s, comp, 32, 24), BLUE), "existing layers see the refreshed source");
assert!(std::sync::Arc::ptr_eq(&cached, &s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap()));
assert_eq!(s.history.undo.len(), undo + 1, "composition import stays one undo step");
s.undo();
assert!(std::sync::Arc::ptr_eq(&s.project, &before));
assert!(near(pixel(&s, comp, 32, 24), BLUE), "undo does not undo an external file write");
std::fs::remove_file(path).unwrap();
std::fs::remove_file(other).unwrap();
}
#[test]
fn background_import_refreshes_layered_sources_before_the_probe_job_finishes() {
let path = tmp("layered-background.psd");
let other = tmp("layered-background.png");
std::fs::write(&path, flat(RED, false)).unwrap();
image::RgbaImage::from_pixel(64, 48, image::Rgba([0, 255, 0, 255])).save(&other).unwrap();
let mut s = effectcraft_host::session();
let comp = layered_comp(&mut s, &path, "compositionLayerSizes");
assert!(near(pixel(&s, comp, 32, 24), RED));
save(&path, &flat(BLUE, false));
s.drain_events();
let r = s.execute_checked("file.import", json!({"paths": [&path, &other], "importAs": "compositionLayerSizes", "background": true})).unwrap();
assert_eq!(r["started"], true, "ordinary footage is probed in a background job");
assert!(near(pixel(&s, comp, 32, 24), BLUE), "composition sources are refreshed before the job is applied");
s.wait_jobs();
assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview");
assert!(near(pixel(&s, s.active_comp_id().unwrap(), 32, 24), BLUE));
std::fs::remove_file(path).unwrap();
std::fs::remove_file(other).unwrap();
}
+48
View File
@@ -0,0 +1,48 @@
//! Targa import through the same host and commands used by the desktop and CLI.
use effectcraft_engine::render::RenderOpts;
use effectcraft_project::{FootageKind, ItemId, ItemKind};
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
#[test]
fn tga_stills_and_numbered_sequences_import_and_render() {
let dir = std::env::temp_dir().join(format!("effectcraft-tga-import-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// A generated 4×4, 24-bit, top-origin TGA. No binary fixtures are retained.
let write = |name: &str, bgr: [u8; 3]| {
let mut bytes = vec![0; 18];
bytes[2] = 2;
bytes[12] = 4;
bytes[14] = 4;
bytes[16] = 24;
bytes[17] = 0x20;
bytes.extend_from_slice(&bgr.repeat(16));
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().to_string()
};
let still = write("still.TGA", [0, 0, 255]);
let first = write("shot_0001.tga", [0, 0, 255]);
write("shot_0002.tga", [255, 0, 0]);
for (path, sequence) in [(still, false), (first, true)] {
let mut session = effectcraft_host::session();
let imported = session.execute_checked("file.import", json!({"paths": [path], "sequence": sequence})).unwrap();
assert_eq!(imported["errors"], json!([]), "{imported}");
assert_eq!(imported["items"].as_array().unwrap().len(), 1);
let item = ItemId(imported["items"][0].as_u64().unwrap());
let ItemKind::Footage(footage) = &session.project.item(item).unwrap().kind else { panic!("not footage") };
assert_eq!(footage.kind, if sequence { FootageKind::Sequence } else { FootageKind::Still });
assert_eq!(footage.sequence.len(), if sequence { 2 } else { 0 });
session.execute_checked("comp.new", json!({"name": "TGA", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap();
session.execute_checked("layer.addItem", json!({"item": item.0})).unwrap();
let comp = session.active_comp_id().unwrap();
let image = session.render(comp, Tick::ZERO, RenderOpts::default());
assert_eq!(image.get(2, 2), [1.0, 0.0, 0.0, 1.0]);
if sequence {
let image = session.render(comp, FrameRate::FPS_30.tick_of(1), RenderOpts::default());
assert_eq!(image.get(2, 2), [0.0, 0.0, 1.0, 1.0]);
}
}
std::fs::remove_dir_all(dir).unwrap();
}
+1
View File
@@ -10,6 +10,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
craft-docpath = { workspace = true }
effectcraft-time = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
+22 -2
View File
@@ -88,6 +88,21 @@ impl Ctx<'_> {
}
}
/// The file an external image asset (`u` + `p`) names, taken from `base_dir`, the folder of the
/// Lottie file. Only local files, or files on the network share the Lottie file is on, are
/// imported (see `craft_docpath`).
fn external_image(stored: &str, base_dir: &str) -> Result<String, String> {
let doc = (!base_dir.is_empty()).then(|| {
let doc = std::path::Path::new(base_dir).join("lottie.json");
std::path::absolute(&doc).unwrap_or(doc)
});
match craft_docpath::resolve(stored, doc.as_deref()) {
Ok(path) if path.is_absolute() => Ok(path.to_string_lossy().into_owned()),
Ok(_) => Err(format!("{stored} is relative and the Lottie file's folder is not known")),
Err(why) => Err(format!("{stored}: {why}")),
}
}
/// Import a Lottie document (JSON or a stored `.lottie` archive) into `project` as a new
/// composition. `store` saves embedded images and returns the file path to use;
/// `base_dir` resolves external image references.
@@ -145,8 +160,13 @@ pub fn import(
(cx.store)(&ImportedImage { id: id.to_string(), name: nm.clone(), bytes: data, ext: ext.into() })
} else {
let u = s(a, "u").unwrap_or("");
let rel = format!("{u}{p}");
Some(if rel.starts_with('/') || cx.base_dir.is_empty() { rel } else { format!("{}/{rel}", cx.base_dir.trim_end_matches('/')) })
match external_image(&format!("{u}{p}"), cx.base_dir) {
Ok(path) => Some(path),
Err(why) => {
cx.im.warn(format!("image asset `{id}` is not imported: {why}"));
continue;
}
}
};
let Some(path) = path else {
cx.im.warn(format!("image asset `{id}` could not be stored"));
+23
View File
@@ -806,3 +806,26 @@ fn extreme_enum_numbers_do_not_overflow() {
let mut q = Project::default();
import(&mut q, doc.to_string().as_bytes(), "x", "", &mut |_| None).unwrap();
}
/// External image assets are taken from the Lottie file's folder; one on another computer is not
/// imported.
#[test]
fn external_images_are_local_or_skipped() {
let base = std::env::temp_dir().join("ec-lottie-anim");
let mut assets = vec![
serde_json::json!({"id": "near", "u": "images/", "p": "a.png", "w": 1, "h": 1}),
serde_json::json!({"id": "unc", "u": "", "p": r"\\192.0.2.1\share\x.png", "w": 1, "h": 1}),
serde_json::json!({"id": "url", "u": "file://192.0.2.1/s/", "p": "x.png", "w": 1, "h": 1}),
];
// `u` + `p` is `//tmp/…/a.png`, which Linux reads as a local file.
if cfg!(unix) {
assets.push(serde_json::json!({"id": "slashes", "u": "/", "p": base.join("a.png").to_string_lossy(), "w": 1, "h": 1}));
}
let doc = serde_json::json!({"w": 10, "h": 10, "fr": 30, "ip": 0, "op": 30, "assets": assets, "layers": []});
let mut q = Project::default();
let r = import(&mut q, doc.to_string().as_bytes(), "x", &base.to_string_lossy(), &mut |_| None).unwrap();
let paths: Vec<String> = q.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect();
assert_eq!(paths.len(), 1, "{paths:?}");
assert_eq!(std::path::Path::new(&paths[0]), base.join("images").join("a.png"));
assert_eq!(r.warnings.iter().filter(|w| w.contains("is not imported")).count(), assets.len() - 1, "{:?}", r.warnings);
}
+20 -3
View File
@@ -70,8 +70,16 @@ pub fn store(entries: &[(String, Vec<u8>)]) -> Vec<u8> {
out
}
/// Whether an entry name is a relative path inside the archive: not absolute (`/…`, `\…`), no
/// drive (`C:…`) and no `..` component.
fn is_relative_name(name: &str) -> bool {
let drive = name.as_bytes().get(1) == Some(&b':');
!name.starts_with(['/', '\\']) && !drive && !name.split(['/', '\\']).any(|c| c == "..")
}
/// Read the stored (uncompressed) entries of a ZIP archive written by [`store`] or any other
/// writer that did not compress. Compressed entries are skipped.
/// writer that did not compress. Compressed entries and entries whose names are not relative
/// paths inside the archive are skipped.
pub fn read_stored(data: &[u8]) -> Vec<(String, Vec<u8>)> {
let mut out = vec![];
let mut i = 0usize;
@@ -82,8 +90,9 @@ pub fn read_stored(data: &[u8]) -> Vec<(String, Vec<u8>)> {
let name_start = i + 30;
let body = name_start + nlen + xlen;
let (Some(name), Some(bytes)) = (data.get(name_start..name_start + nlen), data.get(body..body + size)) else { break };
if method == 0 {
out.push((String::from_utf8_lossy(name).to_string(), bytes.to_vec()));
let name = String::from_utf8_lossy(name).to_string();
if method == 0 && is_relative_name(&name) {
out.push((name, bytes.to_vec()));
}
i = body + size;
}
@@ -101,4 +110,12 @@ mod tests {
assert_eq!(back[1].0, "b/c.txt");
assert_eq!(back[1].1, b"hello");
}
#[test]
fn entries_outside_the_archive_root_are_skipped() {
let names = ["../up.txt", "a/../../up.txt", "/abs/x", "\\abs\\x", "C:\\x", "c:x", "a\\..\\x", "ok/x.txt", "ok/..x"];
let z = super::store(&names.iter().map(|n| (n.to_string(), b"x".to_vec())).collect::<Vec<_>>());
let back: Vec<String> = super::read_stored(&z).into_iter().map(|(n, _)| n).collect();
assert_eq!(back, ["ok/x.txt", "ok/..x"]);
}
}
+1
View File
@@ -10,6 +10,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
craft-docpath = { workspace = true }
effectcraft-time = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
+25 -2
View File
@@ -557,9 +557,9 @@ impl Inner {
return m.clone();
}
let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| {
let dir = std::path::Path::new(path).parent().map(|d| d.to_path_buf()).unwrap_or_default();
// Files the model names are read only when local or on the model's own network share.
let resolve = |uri: &str| -> Option<Vec<u8>> {
let p = dir.join(uri);
let p = craft_docpath::resolve(uri, Some(std::path::Path::new(path))).ok().filter(|p| p.is_absolute())?;
self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec())
};
effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string())
@@ -1140,4 +1140,27 @@ mod tests {
assert_eq!(px(&f, FloatCurve::Srgb), srgb);
assert_eq!(px(&f, FloatCurve::Gamma24), g24);
}
/// A model's material library is read beside it; one named by a path to another computer
/// is not read.
#[test]
fn model_files_on_other_computers_are_not_read() {
let dir = std::env::temp_dir().join(format!("ec-model-files-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("red.mtl"), "newmtl red\nKd 1 0 0\n").unwrap();
let pool = MediaPool::new();
let red = |name: &str, mtl: &str| {
let path = dir.join(name);
std::fs::write(&path, format!("mtllib {mtl}\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl red\nf 1 2 3\n")).unwrap();
let model = pool.inner.model(&path.to_string_lossy()).unwrap();
model.materials.iter().any(|m| m.base_color[..3] == [1.0, 0.0, 0.0])
};
assert!(red("beside.obj", "red.mtl"));
assert!(!red("unc.obj", r"\\192.0.2.1\share\red.mtl"));
assert!(!red("url.obj", "file://192.0.2.1/s/red.mtl"));
// Linux reads `//dir/red.mtl` as the local file.
#[cfg(unix)]
assert!(!red("slashes.obj", &format!("/{}", dir.join("red.mtl").display())));
let _ = std::fs::remove_dir_all(&dir);
}
}
+98 -1
View File
@@ -19,8 +19,10 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
// A movie is read in place (its index), not loaded whole.
if let Some(src) = crate::stream::probe_source(path) {
let mut f = footage_from_info(&path.to_string_lossy(), src?.info());
let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h));
let header = crate::stream::movie_header(path);
let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn);
turn_footage(&mut f, turn);
correct_rate(&mut f, header.as_deref().and_then(video_rate));
return Ok(f);
}
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
@@ -95,12 +97,24 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
}
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let turn = video_turn(&bytes);
let rate = video_rate(&bytes);
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
let mut f = footage_from_info(path, src.info());
turn_footage(&mut f, turn);
correct_rate(&mut f, rate);
Ok(f)
}
/// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's
/// own estimate misreads it.
fn correct_rate(f: &mut Footage, rate: Option<FrameRate>) {
if f.kind == FootageKind::Video
&& let Some(rate) = rate
{
f.frame_rate = rate;
}
}
/// The size as displayed: a movie shot with the camera on its side trades width and height.
fn turn_footage(f: &mut Footage, turn: Orientation) {
if swaps_sides(turn) {
@@ -254,3 +268,86 @@ pub(crate) fn video_turn(bytes: &[u8]) -> Orientation {
(false, _, false) => Orientation::Rotate270,
}
}
/// The frame rate of an MP4/MOV movie's video track when the reader's own estimate, the median
/// frame duration, misreads it. Phones and messaging apps store frame times rounded to whole
/// milliseconds: a 30 fps clip's frames last 33 or 34 ms and a 60 fps clip's 16 or 17 ms, whose
/// median reads as 30.303 or 58.824 fps. `None` for other containers and for movies the median
/// reads right.
fn video_rate(bytes: &[u8]) -> Option<FrameRate> {
let file = filmcraft_isobmff::open(bytes).ok()?;
let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?;
averaged_rate(t.timescale, t.samples.iter().take(240).map(|s| s.duration))
}
/// The standard rate ([`FrameRate::COMMON`]) within 0.5 % of what the frame durations near their
/// median average to (`durations` in `1 / timescale` s); `None` when the median duration is a
/// standard rate already, or when the average is no standard rate (variable or unusual rates keep
/// the reader's estimate).
fn averaged_rate(timescale: u32, durations: impl Iterator<Item = u32>) -> Option<FrameRate> {
let ts = i128::from(timescale);
let mut durs: Vec<u64> = durations.filter(|&d| d > 0).map(u64::from).collect();
durs.sort_unstable();
let median = *durs.get(durs.len() / 2)?;
if FrameRate::COMMON.iter().any(|r| ts * i128::from(r.den) == i128::from(median) * i128::from(r.num)) {
return None;
}
// Durations within a quarter of the median (widened, so extreme durations can't overflow).
let (n, sum) = durs.iter().filter(|&&d| d.abs_diff(median).saturating_mul(4) <= median).fold((0u64, 0u64), |(n, s), &d| (n + 1, s.saturating_add(d)));
if sum == 0 {
return None;
}
let fps = timescale as f64 * n as f64 / sum as f64;
FrameRate::COMMON
.into_iter()
.map(|r| (r, (fps / r.as_f64() - 1.0).abs()))
.filter(|&(_, off)| off <= 0.005)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(r, _)| r)
}
#[cfg(test)]
mod tests {
use super::*;
/// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale.
fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec<u32> {
let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num);
(0..n).map(|k| (at(k + 1) - at(k)) as u32).collect()
}
#[test]
fn millisecond_rounded_frame_times_average_to_their_rate() {
assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]);
assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_30, 1000, 120).into_iter()), Some(FrameRate::FPS_30));
assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_60, 1000, 240).into_iter()), Some(FrameRate::FPS_60));
// NTSC rates stay NTSC, not the round rate next to them.
for r in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] {
assert_eq!(averaged_rate(1000, rounded(r, 1000, 240).into_iter()), Some(r), "{r:?}");
}
// A short clip: its few frames average to the rate as well.
assert_eq!(averaged_rate(1000, [33, 34, 33].into_iter()), Some(FrameRate::FPS_30));
}
#[test]
fn exact_and_variable_rates_keep_the_readers_estimate() {
// A median that is a standard rate already reads right.
assert_eq!(averaged_rate(30000, rounded(FrameRate::FPS_29_97, 30000, 240).into_iter()), None);
assert_eq!(averaged_rate(90000, [3000; 10].into_iter()), None);
assert_eq!(averaged_rate(600, [20, 20, 19, 21, 20].into_iter()), None);
// Durations averaging to no standard rate (22.2 fps).
assert_eq!(averaged_rate(1000, [40, 45, 50].repeat(20).into_iter()), None);
// A truly 30.3 fps clip is not pulled to 30 (1 % off).
assert_eq!(averaged_rate(1000, [33; 30].into_iter()), None);
}
#[test]
fn degenerate_durations_have_no_rate() {
assert_eq!(averaged_rate(1000, std::iter::empty()), None);
assert_eq!(averaged_rate(1000, [0, 0, 0].into_iter()), None);
assert_eq!(averaged_rate(0, [33, 34, 33].into_iter()), None);
assert_eq!(averaged_rate(u32::MAX, [u32::MAX, u32::MAX - 1, 1, 0].into_iter()), None);
assert_eq!(averaged_rate(u32::MAX, [u32::MAX; 300].into_iter()), None);
assert_eq!(video_rate(b"not a movie"), None);
}
}
+115
View File
@@ -0,0 +1,115 @@
//! Tiny synthetic Targa files exercise import without external fixtures or codecs.
use std::path::Path;
use std::sync::Arc;
use effectcraft_media::{MediaPool, is_importable, probe_bytes};
use effectcraft_project::{AlphaMode, Footage, FootageKind};
use effectcraft_time::Tick;
/// A classic 2×2 TGA without a footer. True-colour pixels are stored BGR(A); RLE
/// packets repeat each row's colour twice. `pixels` is always in top-down RGBA order.
fn tga(pixels: [[u8; 4]; 4], alpha: bool, rle: bool, top: bool) -> Vec<u8> {
let mut bytes = vec![0; 18];
bytes[2] = if rle { 10 } else { 2 };
bytes[12] = 2;
bytes[14] = 2;
bytes[16] = if alpha { 32 } else { 24 };
bytes[17] = if top { 0x20 } else { 0 } | if alpha { 8 } else { 0 };
let rows = if top { [[0, 1], [2, 3]] } else { [[2, 3], [0, 1]] };
for row in rows {
if rle {
assert_eq!(pixels[row[0]], pixels[row[1]]);
bytes.push(0x81); // A two-pixel run.
}
for &i in row.iter().take(if rle { 1 } else { 2 }) {
let [r, g, b, a] = pixels[i];
bytes.extend_from_slice(&[b, g, r]);
if alpha {
bytes.push(a);
}
}
}
bytes
}
#[test]
fn tga_raw_and_rle_preserve_colours_orientation_and_alpha() {
for alpha in [false, true] {
for rle in [false, true] {
for top in [false, true] {
let pixels = if rle {
[[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 255], [0, 0, 255, 255]]
} else {
[[255, 0, 0, 128], [0, 255, 0, 255], [0, 0, 255, 64], [255, 255, 255, 0]]
};
let path = if top { "/frame.TGA" } else { "/frame.tga" };
assert!(is_importable(Path::new(path)));
let bytes: Arc<[u8]> = tga(pixels, alpha, rle, top).into();
let footage = probe_bytes(path, bytes.clone()).unwrap();
assert_eq!(footage.kind, FootageKind::Still);
assert_eq!((footage.width, footage.height), (2, 2));
assert_eq!(footage.codec, "TGA");
assert_eq!(footage.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore });
assert!(footage.has_video && !footage.has_audio);
let pool = MediaPool::new();
pool.add_bytes(path, bytes);
let image = pool.frame_at(&footage, Tick::ZERO).unwrap();
for (actual, [r, g, b, a]) in image.data.iter().zip(pixels) {
let a = if alpha { f32::from(a) / 255.0 } else { 1.0 };
let expected = [f32::from(r) / 255.0 * a, f32::from(g) / 255.0 * a, f32::from(b) / 255.0 * a, a];
assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{actual:?} != {expected:?}");
}
}
}
}
}
#[test]
fn tga_sequence_decodes_each_frame_from_memory() {
let pool = MediaPool::new();
pool.set_read_ahead(false);
let paths = ["/shot_0001.tga", "/shot_0002.tga"];
let colours = [[255, 0, 0, 255], [0, 0, 255, 255]];
let mut footage = None;
for (path, colour) in paths.iter().zip(colours) {
let bytes: Arc<[u8]> = tga([colour; 4], false, true, true).into();
footage.get_or_insert_with(|| probe_bytes(path, bytes.clone()).unwrap());
pool.add_bytes(path, bytes);
}
let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.iter().map(|s| s.to_string()).collect(), ..footage.unwrap() };
footage.sync_sequence_duration();
for (n, expected) in [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]].into_iter().enumerate() {
let image = pool.frame_at(&footage, footage.frame_rate.tick_of(n as i64)).unwrap();
assert_eq!(image.get(0, 0), expected);
}
}
#[test]
fn malformed_tga_returns_contextual_errors() {
for bytes in [vec![], vec![0; 17], vec![0; 18]] {
let error = probe_bytes("/broken.tga", bytes.into()).unwrap_err().to_string();
assert!(error.contains("/broken.tga"), "{error}");
}
let mut bytes = tga([[255, 0, 0, 255]; 4], true, false, true);
bytes.truncate(19); // Valid metadata, truncated pixel data.
let bytes: Arc<[u8]> = bytes.into();
let footage = probe_bytes("/truncated.tga", bytes.clone()).unwrap();
let pool = MediaPool::new();
pool.add_bytes(&footage.path, bytes);
let error = pool.frame_at(&footage, Tick::ZERO).unwrap_err().to_string();
assert!(error.contains("/truncated.tga"), "{error}");
}
#[test]
fn a_recognised_signature_takes_precedence_over_the_tga_extension() {
let mut bytes = std::io::Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(3, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
let bytes: Arc<[u8]> = bytes.into_inner().into();
let footage = probe_bytes("/actually-png.tga", bytes.clone()).unwrap();
assert_eq!(footage.codec, "PNG");
let pool = MediaPool::new();
pool.add_bytes(&footage.path, bytes);
let image = pool.frame_at(&footage, Tick::ZERO).unwrap();
assert_eq!((image.width, image.height), (3, 2));
}
+91 -11
View File
@@ -32,6 +32,57 @@ pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0";
const MAX_DEPTH: usize = 64;
/// Largest decoded array accepted (bytes).
const MAX_ARRAY_BYTES: usize = 1 << 30;
/// Most memory one import may keep: parsed arrays as stored (8 bytes per element, whatever the
/// wire type) plus the meshes built from them (#1234).
const MAX_IMPORT_BYTES: usize = 1 << 31;
/// What is left of an import's memory budget.
struct Budget(std::cell::Cell<usize>);
impl Budget {
fn new(bytes: usize) -> Budget {
Budget(std::cell::Cell::new(bytes))
}
/// Take `bytes` (None: a size that overflowed) from the budget, or fail the import before
/// anything that large is allocated.
fn take(&self, bytes: Option<usize>, what: &str) -> Result<()> {
let left = self.0.get();
match bytes.filter(|&b| b <= left) {
Some(b) => {
self.0.set(left - b);
Ok(())
}
None => Err(perr(format!("FBX: {what} would take the import past its {} MiB memory limit", MAX_IMPORT_BYTES >> 20))),
}
}
}
/// Bytes a mesh keeps.
fn mesh_bytes(m: &Mesh) -> Option<usize> {
m.primitives.iter().try_fold(0usize, |acc, p| {
let n = p.positions.len().checked_mul(12)?.checked_add(p.normals.len().checked_mul(12)?)?.checked_add(p.uvs.len().checked_mul(8)?)?;
let n = n.checked_add(p.tangents.len().checked_mul(16)?)?.checked_add(p.joints.len().checked_mul(8)?)?.checked_add(p.weights.len().checked_mul(16)?)?;
acc.checked_add(n)?.checked_add(p.indices.len().checked_mul(4)?)
})
}
/// Bytes the values under `nodes` keep. Used for ASCII files, which hold array elements as
/// one `Prop` each and are parsed before they can be counted (the text itself bounds them).
fn value_bytes(nodes: &[FbxNode]) -> Option<usize> {
nodes.iter().try_fold(0usize, |acc, n| {
let own = n.props.iter().try_fold(0usize, |a, p| {
let extra = match p {
Prop::Ints(v) => v.len().checked_mul(8)?,
Prop::Floats(v) => v.len().checked_mul(8)?,
Prop::Str(v) => v.len(),
Prop::Bytes(v) => v.len(),
Prop::Int(_) | Prop::Float(_) => 0,
};
a.checked_add(std::mem::size_of::<Prop>())?.checked_add(extra)
})?;
acc.checked_add(own)?.checked_add(value_bytes(&n.children)?)
})
}
/// One property value of an FBX node.
#[derive(Clone, Debug, PartialEq)]
@@ -126,7 +177,17 @@ impl FbxNode {
/// Read an FBX file (binary or ASCII) into its top-level nodes and the file version
/// (e.g. 7400).
pub fn read(bytes: &[u8]) -> Result<(u32, Vec<FbxNode>)> {
if bytes.starts_with(BINARY_MAGIC) { read_binary(bytes) } else { read_ascii(&String::from_utf8_lossy(bytes)) }
read_within(bytes, &Budget::new(MAX_IMPORT_BYTES))
}
fn read_within(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec<FbxNode>)> {
if bytes.starts_with(BINARY_MAGIC) {
read_binary(bytes, budget)
} else {
let (version, nodes) = read_ascii(&String::from_utf8_lossy(bytes))?;
budget.take(value_bytes(&nodes), "its values")?;
Ok((version, nodes))
}
}
// ---------------------------------------------------------------------------------------------
@@ -136,6 +197,7 @@ struct Bin<'a> {
d: &'a [u8],
/// 64-bit record offsets (version 7500 and later).
wide: bool,
budget: &'a Budget,
}
impl Bin<'_> {
@@ -207,7 +269,7 @@ impl Bin<'_> {
}
fn prop(&self, pos: &mut usize, pend: usize) -> Result<Prop> {
let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide };
let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide, budget: self.budget };
let t = sub.arr::<1>(pos)?[0];
Ok(match t {
b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64),
@@ -234,6 +296,9 @@ impl Bin<'_> {
_ => 1,
};
let want = len.checked_mul(size).filter(|&n| n <= MAX_ARRAY_BYTES).ok_or_else(|| perr("FBX: array too large"))?;
// Elements are kept as f64/i64 (booleans and 32-bit values widen), so the budget
// is charged for what is kept, not for the wire bytes.
sub.budget.take(len.checked_mul(8), "an array")?;
let data = sub.take(pos, clen)?;
let raw = match enc {
0 => data.to_vec(),
@@ -263,10 +328,10 @@ fn object_name(b: &[u8]) -> String {
}
}
fn read_binary(bytes: &[u8]) -> Result<(u32, Vec<FbxNode>)> {
fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec<FbxNode>)> {
let mut pos = BINARY_MAGIC.len();
let version = Bin { d: bytes, wide: false }.u32(&mut pos)?;
let bin = Bin { d: bytes, wide: version >= 7500 };
let version = Bin { d: bytes, wide: false, budget }.u32(&mut pos)?;
let bin = Bin { d: bytes, wide: version >= 7500, budget };
let mut nodes = vec![];
while pos < bytes.len() {
match bin.record(&mut pos, 0) {
@@ -728,7 +793,13 @@ fn short_name(full: &str) -> &str {
/// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it.
pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
let (version, doc) = read(bytes)?;
parse_within(bytes, resolve, MAX_IMPORT_BYTES)
}
/// [`parse`] keeping at most `limit` bytes of arrays and meshes.
fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>, limit: usize) -> Result<Model> {
let budget = Budget::new(limit);
let (version, doc) = read_within(bytes, &budget)?;
if version != 0 && version < 7000 {
return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100)));
}
@@ -835,19 +906,28 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
Some(materials.len() - 1)
};
// One mesh per Model with Mesh geometry (material slots are per Model).
// One mesh per Model with Mesh geometry (material slots are per Model). Models instancing the
// same geometry with the same materials share one mesh rather than each building a copy.
let mut meshes: Vec<Mesh> = vec![];
let mut mesh_of: HashMap<(i64, Vec<usize>), usize> = HashMap::new();
for &id in &model_ids {
let Some(&ni) = node_of.get(&id) else { continue };
let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue };
let Some(geom) = by_id.get(&gid) else { continue };
let mats: Vec<usize> =
links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect();
let normal_mapped: Vec<bool> = materials.iter().map(|m| m.normal_tex.is_some()).collect();
let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default();
let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue };
meshes.push(mesh);
let mesh_index = meshes.len() - 1;
let mesh_index = match mesh_of.get(&(gid, mats.clone())) {
Some(&i) => i,
None => {
let normal_mapped: Vec<bool> = materials.iter().map(|m| m.normal_tex.is_some()).collect();
let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue };
budget.take(mesh_bytes(&mesh), &format!("mesh `{name}`"))?;
meshes.push(mesh);
mesh_of.insert((gid, mats), meshes.len() - 1);
meshes.len() - 1
}
};
let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o)));
match geo {
Some(matrix) => {
+84
View File
@@ -325,3 +325,87 @@ fn hostile_fbx_files_fail_cleanly() {
assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_))));
assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err());
}
/// A version-7400 binary FBX holding one top-level node `A` with uncompressed arrays of type
/// `t` (`b'b'`, `b'i'`, `b'f'`, `b'd'`, `b'l'`) and `len` elements each.
fn binary_arrays(arrays: &[(u8, usize)]) -> Vec<u8> {
let mut props = vec![];
for &(t, len) in arrays {
let size = match t {
b'd' | b'l' => 8,
b'f' | b'i' => 4,
_ => 1,
};
props.push(t);
props.extend((len as u32).to_le_bytes());
props.extend(0u32.to_le_bytes());
props.extend(((len * size) as u32).to_le_bytes());
props.extend(std::iter::repeat_n(1u8, len * size));
}
let mut out = BINARY_MAGIC.to_vec();
out.extend(7400u32.to_le_bytes());
let end = out.len() + 13 + 1 + props.len();
out.extend((end as u32).to_le_bytes());
out.extend((arrays.len() as u32).to_le_bytes());
out.extend((props.len() as u32).to_le_bytes());
out.push(1);
out.push(b'A');
out.extend(props);
out
}
#[test]
fn fbx_arrays_are_charged_for_the_memory_they_keep() {
// A boolean array is one byte per element on the wire but eight once kept as `Prop::Ints`:
// the budget counts the eight (#1234).
let n = 10_000;
let bin = binary_arrays(&[(b'b', n)]);
assert!(read_within(&bin, &Budget::new(n * 8)).is_ok());
let e = read_within(&bin, &Budget::new(n * 8 - 1)).unwrap_err();
assert!(e.to_string().contains("memory limit"), "{e}");
// 32-bit floats widen to f64 the same way.
assert!(read_within(&binary_arrays(&[(b'f', n)]), &Budget::new(n * 4)).is_err());
// Several arrays each within the budget but not together.
let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]);
assert!(read_within(&bin, &Budget::new(n * 24)).is_ok());
assert!(read_within(&bin, &Budget::new(n * 20)).is_err());
// ASCII values count too.
let (_, doc) = read(ASCII.as_bytes()).unwrap();
let values = value_bytes(&doc).unwrap();
assert!(values > 45 * std::mem::size_of::<Prop>(), "{values}");
assert!(parse_within(ASCII.as_bytes(), &|_| None, values - 1).unwrap_err().to_string().contains("memory limit"));
}
/// [`ASCII`] with two more Models instancing its geometry: `Copy` with the same two materials,
/// `Red only` with just the first.
fn instanced_scene() -> String {
ASCII
.replace(
"\tMaterial: 400,",
"\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\n\tMaterial: 400,",
)
.replace(
"\tC: \"OO\",500,300\n",
"\tC: \"OO\",500,300\n\tC: \"OO\",301,0\n\tC: \"OO\",100,301\n\tC: \"OO\",400,301\n\tC: \"OO\",500,301\n\tC: \"OO\",302,0\n\tC: \"OO\",100,302\n\tC: \"OO\",400,302\n",
)
}
#[test]
fn fbx_models_instancing_one_geometry_share_its_mesh() {
let m = parse(instanced_scene().as_bytes(), &|_| None).unwrap();
let mesh_of = |name: &str| {
let n = m.nodes.iter().position(|n| n.name == name).unwrap();
// The mesh sits on the node, or on its geometric-offset child.
m.nodes[n].mesh.or_else(|| m.nodes[n].children.iter().find_map(|&c| m.nodes[c].mesh)).unwrap()
};
assert_eq!(mesh_of("Shape"), mesh_of("Copy"), "same geometry and materials: one mesh");
assert_ne!(mesh_of("Shape"), mesh_of("Red only"), "different materials: its own mesh");
assert_eq!(m.meshes.len(), 2);
// A budget with room for one copy of the geometry's mesh, but not three, still imports.
let one = parse(ASCII.as_bytes(), &|_| None).unwrap();
let mesh = mesh_bytes(&one.meshes[0]).unwrap();
let (_, doc) = read(instanced_scene().as_bytes()).unwrap();
let values = value_bytes(&doc).unwrap();
assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + 2 * mesh).is_ok());
assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + mesh).is_err());
}
+65 -22
View File
@@ -39,13 +39,15 @@ pub fn split_glb(b: &[u8]) -> Result<(&[u8], Option<&[u8]>)> {
while o + 8 <= total {
let len = u32_at(o).unwrap_or(0) as usize;
let ty = u32_at(o + 4).unwrap_or(0);
let data = b.get(o + 8..o + 8 + len).ok_or_else(|| perr("GLB chunk runs past the end of the file"))?;
let past_end = || perr("GLB chunk runs past the end of the file");
let end = (o + 8).checked_add(len).ok_or_else(past_end)?;
let data = b.get(o + 8..end).ok_or_else(past_end)?;
match ty {
CHUNK_JSON if json.is_none() => json = Some(data),
CHUNK_BIN if bin.is_none() => bin = Some(data),
_ => {}
}
o += 8 + len;
o = end;
}
Ok((json.ok_or_else(|| perr("GLB without a JSON chunk"))?, bin))
}
@@ -115,8 +117,25 @@ struct Doc<'a> {
fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] {
v.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])
}
/// A non-negative integer field; one too large for `usize` counts as absent (#1235).
fn idx(v: &Value, k: &str) -> Option<usize> {
v.get(k).and_then(Value::as_u64).map(|x| x as usize)
v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok())
}
/// Node indices in an array field (out-of-range ones are dropped).
fn indices(v: &Value, k: &str) -> Vec<usize> {
arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).collect()
}
/// The most components an accessor without a buffer view (all zeros) may have: nothing in the
/// file backs its count, so it is capped instead of checked against a buffer (#1235).
const MAX_ZERO_COMPONENTS: usize = 1 << 24;
/// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride`
/// apart from `base`, all checked to be inside `buf`.
struct Span<'b> {
buf: &'b [u8],
base: usize,
stride: usize,
}
fn num(v: &Value, k: &str, d: f64) -> f64 {
v.get(k).and_then(Value::as_f64).unwrap_or(d)
@@ -132,6 +151,39 @@ fn nums<const N: usize>(v: &Value, k: &str, d: [f64; N]) -> [f64; N] {
}
impl Doc<'_> {
/// The accessor's element count and total components (`count * n`), checked; and its span in
/// the buffer, validated before anything is allocated from the count. `None` for an
/// accessor without a buffer view, whose components are all zero.
fn layout(&self, acc: &Value, n: usize, csize: usize) -> Result<(usize, usize, Option<Span<'_>>)> {
let count = match acc.get("count") {
None => 0,
Some(c) => c.as_u64().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| perr("accessor count is not a valid size"))?,
};
let total = count.checked_mul(n).ok_or_else(|| perr("accessor count is too large"))?;
let Some(bv) = idx(acc, "bufferView") else {
if total > MAX_ZERO_COMPONENTS {
return Err(perr(format!("accessor without a bufferView has {total} components (at most {MAX_ZERO_COMPONENTS})")));
}
return Ok((count, total, None));
};
let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?;
let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?;
let past = || perr("accessor reads past its buffer");
let base = idx(view, "byteOffset").unwrap_or(0).checked_add(idx(acc, "byteOffset").unwrap_or(0)).ok_or_else(past)?;
let elem = csize * n;
let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem);
if stride < elem {
return Err(perr(format!("bufferView byteStride {stride} is smaller than its {elem}-byte elements")));
}
if count > 0 {
let end = (count - 1).checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(elem)).ok_or_else(past)?;
if end > buf.len() {
return Err(past());
}
}
Ok((count, total, Some(Span { buf, base, stride })))
}
/// An accessor as `f32` rows of `n` components (normalised integers mapped to 0..1 / -1..1).
fn floats(&self, a: usize) -> Result<(Vec<f32>, usize)> {
let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?;
@@ -145,7 +197,6 @@ impl Doc<'_> {
"MAT4" => 16,
t => return Err(perr(format!("accessor type `{t}`"))),
};
let count = idx(acc, "count").unwrap_or(0);
let ct = idx(acc, "componentType").unwrap_or(5126);
let norm = acc.get("normalized").and_then(Value::as_bool).unwrap_or(false);
let csize = match ct {
@@ -154,17 +205,13 @@ impl Doc<'_> {
5125 | 5126 => 4,
_ => return Err(perr(format!("componentType {ct}"))),
};
let mut out = vec![0.0f32; count * n];
let Some(bv) = idx(acc, "bufferView") else {
let (count, total, span) = self.layout(acc, n, csize)?;
let mut out = vec![0.0f32; total];
let Some(Span { buf, base, stride }) = span else {
// No buffer view: all zeros (sparse substitution is not supported).
return Ok((out, n));
};
let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?;
let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?;
let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0);
let elem = csize * n;
// Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare).
let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem);
for i in 0..count {
for c in 0..n {
let o = base + i * stride + c * csize;
@@ -207,18 +254,14 @@ impl Doc<'_> {
"VEC2" => 2,
_ => 1,
};
let count = idx(acc, "count").unwrap_or(0);
let csize = match ct {
5120 | 5121 => 1,
5122 | 5123 => 2,
_ => 4,
};
let Some(bv) = idx(acc, "bufferView") else { return Ok(vec![0; count * n]) };
let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?;
let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?;
let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0);
let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(csize * n);
let mut out = Vec::with_capacity(count * n);
let (count, total, span) = self.layout(acc, n, csize)?;
let Some(Span { buf, base, stride }) = span else { return Ok(vec![0; total]) };
let mut out = Vec::with_capacity(total);
for i in 0..count {
for c in 0..n {
let o = base + i * stride + c * csize;
@@ -268,7 +311,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
arr(&j, "bufferViews").get(bv).and_then(|v| {
let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?;
let o = idx(v, "byteOffset").unwrap_or(0);
b.get(o..o + idx(v, "byteLength").unwrap_or(0)).map(<[u8]>::to_vec)
b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec)
})
} else {
None
@@ -366,13 +409,13 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
skin: idx(n, "skin"),
camera: idx(n, "camera"),
light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")),
children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(),
children: indices(n, "children"),
});
}
let scene = idx(&j, "scene").unwrap_or(0);
let roots: Vec<usize> = match arr(&j, "scenes").get(scene) {
Some(s) => arr(s, "nodes").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(),
Some(s) => indices(s, "nodes"),
None => {
// No scene: every node that is nobody's child.
let mut child = vec![false; nodes.len()];
@@ -389,7 +432,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
let mut skins = vec![];
for s in arr(&j, "skins") {
let joints: Vec<usize> = arr(s, "joints").iter().filter_map(Value::as_u64).map(|c| c as usize).collect();
let joints: Vec<usize> = indices(s, "joints");
let inverse_bind = match idx(s, "inverseBindMatrices") {
Some(a) => {
let (f, _) = d.floats(a)?;
+72
View File
@@ -492,3 +492,75 @@ fn stl_hostile_input_is_an_error() {
let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap();
assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]);
}
/// A one-triangle glTF whose POSITION is accessor 0, over a buffer of `bytes` zero bytes.
fn gltf_doc(accessors: &str, views: &str, bytes: usize) -> String {
let b64 = "A".repeat(bytes / 3 * 4) + ["", "AA==", "AAA="][bytes % 3];
format!(
r#"{{"asset": {{"version": "2.0"}}, "scenes": [{{"nodes": [0]}}], "nodes": [{{"mesh": 0}}],
"meshes": [{{"primitives": [{{"attributes": {{"POSITION": 0}}}}]}}],
"buffers": [{{"byteLength": {bytes}, "uri": "data:application/octet-stream;base64,{b64}"}}],
"bufferViews": [{views}], "accessors": [{accessors}]}}"#
)
}
#[test]
fn gltf_accessor_counts_are_checked_against_the_buffer_before_allocating() {
// Untrusted counts, offsets and strides must fail the import, not allocate from the count or
// overflow (#1235).
let view = r#"{"buffer": 0, "byteLength": 36}"#;
let cases = [
// A count far beyond the 36-byte buffer.
(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 4000000000}"#, view, "past its buffer"),
// count * components overflows.
(r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 9223372036854775807}"#, view, "too large"),
// Offsets that overflow when added.
(
r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "byteOffset": 16}"#,
r#"{"buffer": 0, "byteOffset": 18446744073709551610}"#,
"past its buffer",
),
// A stride that overflows with the count.
(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3000000000}"#, r#"{"buffer": 0, "byteStride": 252}"#, "past its buffer"),
// A stride smaller than an element would re-read the same bytes for a huge count.
(r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 30}"#, r#"{"buffer": 0, "byteStride": 1}"#, "smaller than"),
// No buffer view: nothing backs the count, so it is capped.
(r#"{"componentType": 5126, "type": "VEC3", "count": 1000000000}"#, view, "without a bufferView"),
];
let t0 = std::time::Instant::now();
for (acc, v, want) in cases {
let doc = gltf_doc(acc, v, 36);
let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("{acc} imported"));
assert!(e.to_string().contains(want), "{acc}: {e}");
}
assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed());
}
#[test]
fn gltf_integer_accessors_are_checked_too() {
let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteLength": 36}"#, 36)
.replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1"#);
for idx in [
r#"{"bufferView": 0, "componentType": 5125, "type": "SCALAR", "count": 4000000000}"#,
r#"{"componentType": 5125, "type": "SCALAR", "count": 4000000000}"#,
r#"{"bufferView": 0, "componentType": 5125, "type": "VEC4", "count": 9223372036854775807}"#,
] {
let doc = doc.replace(r#""count": 3}]"#, &format!(r#""count": 3}}, {idx}]"#));
assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{idx}");
}
}
#[test]
fn gltf_strided_and_buffer_less_accessors_still_import() {
// Interleaved positions (stride 24) ending exactly at the buffer's end, and a small all-zero
// accessor without a view.
let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 60);
let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap();
assert_eq!(m.meshes[0].primitives[0].positions.len(), 3);
let doc = gltf_doc(r#"{"componentType": 5126, "type": "VEC3", "count": 6}"#, r#"{"buffer": 0}"#, 3);
let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap();
assert_eq!(m.meshes[0].primitives[0].positions.len(), 6);
// One byte short of the third element is an error.
let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 59);
assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err());
}
+4
View File
@@ -3,6 +3,10 @@
use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke};
/// The most elements a path being built holds: an operator that would add more stops the
/// PostScript program, or the PDF content stream.
pub(crate) const MAX_PATH: usize = 1 << 20;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Plain,
+7 -2
View File
@@ -12,7 +12,7 @@ use effectcraft_svg::{
};
use kurbo::{Point, Shape as _};
use crate::build::Builder;
use crate::build::{Builder, MAX_PATH};
use crate::color::{Cs, Func, color_space, text_string};
use crate::font::Font;
use crate::object::{Dict, File, Lexer, Obj, decode_stream};
@@ -281,7 +281,12 @@ impl<'a> Interp<'a> {
self.ext_gstate(&g, res, depth);
}
}
// Path construction.
// Path construction. Each operator adds at most five elements (`re`), so when fewer
// than five more fit within `MAX_PATH`, the operator stops the stream.
"m" | "l" | "c" | "v" | "y" | "h" | "re" if self.path.elements().len() > MAX_PATH - 5 => {
self.b.skip("content (path limit)");
return;
}
"m" => {
let p = Point::new(n(0), n(1));
self.path.move_to(p);
+123 -51
View File
@@ -14,7 +14,7 @@ use std::rc::Rc;
use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke};
use kurbo::{PathEl, Point};
use crate::build::Builder;
use crate::build::{Builder, MAX_PATH};
use crate::font::Font;
use crate::object::{ascii85, is_white, parse_num};
@@ -441,7 +441,14 @@ impl Interp {
break;
}
}
other => self.push(other.clone()),
// Other items count against the budget as names do.
other => {
r = self.spend(1);
if r.is_err() {
break;
}
self.push(other.clone());
}
}
}
self.calls -= 1;
@@ -456,11 +463,7 @@ impl Interp {
}
fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> {
if self.budget == 0 {
self.b.skip("PostScript (operation limit)");
return Err(Flow::Quit);
}
self.budget -= 1;
self.spend(1)?;
if let Some(v) = self.lookup(n) {
return match v {
V::Proc(_) => self.exec_obj(v, src),
@@ -473,6 +476,21 @@ impl Interp {
self.builtin(n, src)
}
/// Count `n` operations against the budget, and stop the program when fewer are left. An
/// operator that makes, reads or copies path elements counts each one as an operation.
fn spend(&mut self, n: usize) -> Result<(), Flow> {
match self.budget.checked_sub(n) {
Some(left) => {
self.budget = left;
Ok(())
}
None => {
self.b.skip("PostScript (operation limit)");
Err(Flow::Quit)
}
}
}
fn device_scale(&self) -> f64 {
self.gs.ctm.determinant().abs().sqrt()
}
@@ -483,13 +501,34 @@ impl Interp {
Point::new(x, y)
}
fn move_to(&mut self, p: Point) {
/// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`].
fn path_fits(&mut self, len: usize) -> Result<(), Flow> {
if len <= MAX_PATH {
return Ok(());
}
self.b.skip("PostScript (path limit)");
Err(Flow::Quit)
}
/// [`Self::path_fits`] for the current path with `n` more elements.
fn room(&mut self, n: usize) -> Result<(), Flow> {
self.path_fits(self.gs.path.elements().len().saturating_add(n))
}
fn move_to(&mut self, p: Point) -> Result<(), Flow> {
// A moveto right after a moveto replaces it (PostScript Language Reference §8.2, `moveto`).
if matches!(self.gs.path.elements().last(), Some(PathEl::MoveTo(_))) {
self.gs.path.pop();
}
self.room(1)?;
let q = self.gs.ctm * p;
self.gs.path.move_to(q);
self.gs.cur = Some(q);
self.gs.start = Some(q);
Ok(())
}
fn line_to(&mut self, p: Point) {
fn line_to(&mut self, p: Point) -> Result<(), Flow> {
self.room(1 + usize::from(self.gs.cur.is_none()))?;
let q = self.gs.ctm * p;
if self.gs.cur.is_none() {
self.gs.path.move_to(q);
@@ -497,20 +536,23 @@ impl Interp {
}
self.gs.path.line_to(q);
self.gs.cur = Some(q);
Ok(())
}
fn curve_to(&mut self, a: Point, b: Point, c: Point) {
fn curve_to(&mut self, a: Point, b: Point, c: Point) -> Result<(), Flow> {
self.room(1 + usize::from(self.gs.cur.is_none()))?;
let m = self.gs.ctm;
if self.gs.cur.is_none() {
self.gs.path.move_to(m * a);
}
self.gs.path.curve_to(m * a, m * b, m * c);
self.gs.cur = Some(m * c);
Ok(())
}
fn current_user(&self) -> Point {
self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default()
}
fn arc(&mut self, neg: bool) {
fn arc(&mut self, neg: bool) -> Result<(), Flow> {
let a2 = self.popn();
let a1 = self.popn();
let r = self.popn();
@@ -540,16 +582,23 @@ impl Interp {
let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians());
let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin());
if self.gs.cur.is_some() {
self.line_to(start);
self.line_to(start)?;
} else {
self.move_to(start);
self.move_to(start)?;
}
let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0);
let m = self.gs.ctm;
arc.to_cubic_beziers(0.1, |p1, p2, p3| {
// kurbo divides an arc into more curves the larger the radius (about 3.7 × 10^49 for a
// quarter turn at a radius of 10^300, more than a usize can count), so each one counts
// against the budget and the path limit.
let mut curves = arc.append_iter(0.1);
while let Some(PathEl::CurveTo(p1, p2, p3)) = curves.next() {
self.room(1)?;
self.spend(1)?;
self.gs.path.curve_to(m * p1, m * p2, m * p3);
self.gs.cur = Some(m * p3);
});
}
Ok(())
}
fn paint(&mut self, fill: Option<FillRule>, stroke: bool) {
@@ -569,11 +618,16 @@ impl Interp {
self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st);
}
fn rect_path(&mut self) -> Vec<BezPath> {
fn rect_path(&mut self) -> Result<Vec<BezPath>, Flow> {
// x y w h, or an array / string of numbers.
let top = self.pop();
let nums: Vec<f64> = match &top {
V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(),
V::Array(_) | V::Proc(_) => {
let items = top.items().unwrap_or_default();
// An array's rectangles count against the budget, five path elements each.
self.spend(items.len() / 4 * 5)?;
items.iter().filter_map(V::num).collect()
}
V::Num(h) => {
let w = self.popn();
let y = self.popn();
@@ -582,8 +636,10 @@ impl Interp {
}
_ => vec![],
};
self.path_fits(nums.len() / 4 * 5)?;
let m = self.gs.ctm;
nums.as_chunks::<4>()
Ok(nums
.as_chunks::<4>()
.0
.iter()
.map(|r| {
@@ -595,7 +651,7 @@ impl Interp {
p.close_path();
p
})
.collect()
.collect())
}
fn grestore(&mut self) {
@@ -799,10 +855,12 @@ impl Interp {
let c = matches!(self.pop(), V::Bool(true));
self.exec_obj(if c { t } else { f }, src)?;
}
// Each pass of a loop counts against the budget.
"repeat" => {
let p = self.pop();
let k = self.popn().clamp(0.0, 1e6) as usize;
for _ in 0..k {
self.spend(1)?;
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
r => r?,
@@ -816,6 +874,7 @@ impl Interp {
let mut i = self.popn();
let mut guard = 0;
while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 {
self.spend(1)?;
self.push(V::Num(i));
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
@@ -828,6 +887,7 @@ impl Interp {
"loop" => {
let p = self.pop();
for _ in 0..1_000_000 {
self.spend(1)?;
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
r => r?,
@@ -840,6 +900,7 @@ impl Interp {
V::Dict(d) => {
let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in items {
self.spend(1)?;
self.push(V::Lit(k.into()));
self.push(v);
match self.exec_obj(p.clone(), src) {
@@ -851,6 +912,7 @@ impl Interp {
V::Str(s) => {
let bytes = s.borrow().clone();
for c in bytes {
self.spend(1)?;
self.push(V::Num(c as f64));
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
@@ -860,6 +922,7 @@ impl Interp {
}
a => {
for v in a.items().unwrap_or_default() {
self.spend(1)?;
self.push(v);
match self.exec_obj(p.clone(), src) {
Err(Flow::Exit) => break,
@@ -1117,6 +1180,7 @@ impl Interp {
"languagelevel" => self.push(V::Num(3.0)),
"version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))),
"save" => {
self.spend(self.gs.path.elements().len())?;
self.saved.push(self.gs.clone());
self.gs.depth = self.b.depth();
self.push(V::Null);
@@ -1146,6 +1210,7 @@ impl Interp {
}
// Graphics state.
"gsave" => {
self.spend(self.gs.path.elements().len())?;
let mut g = self.gs.clone();
g.depth = self.b.depth();
self.saved.push(g);
@@ -1306,21 +1371,21 @@ impl Interp {
}
"moveto" => {
let p = self.user_point();
self.move_to(p);
self.move_to(p)?;
}
"rmoveto" => {
let d = self.user_point();
let c = self.current_user();
self.move_to(Point::new(c.x + d.x, c.y + d.y));
self.move_to(Point::new(c.x + d.x, c.y + d.y))?;
}
"lineto" => {
let p = self.user_point();
self.line_to(p);
self.line_to(p)?;
}
"rlineto" => {
let d = self.user_point();
let c = self.current_user();
self.line_to(Point::new(c.x + d.x, c.y + d.y));
self.line_to(Point::new(c.x + d.x, c.y + d.y))?;
}
"curveto" | "rcurveto" => {
let p3 = self.user_point();
@@ -1329,25 +1394,27 @@ impl Interp {
if n == "rcurveto" {
let c = self.current_user();
let o = |p: Point| Point::new(c.x + p.x, c.y + p.y);
self.curve_to(o(p1), o(p2), o(p3));
self.curve_to(o(p1), o(p2), o(p3))?;
} else {
self.curve_to(p1, p2, p3);
self.curve_to(p1, p2, p3)?;
}
}
"closepath" => {
if self.gs.cur.is_some() {
self.room(1)?;
self.gs.path.close_path();
self.gs.cur = self.gs.start;
}
}
"arc" => self.arc(false),
"arcn" => self.arc(true),
"arc" => self.arc(false)?,
"arcn" => self.arc(true)?,
"currentpoint" => {
let c = self.current_user();
self.push(V::Num(c.x));
self.push(V::Num(c.y));
}
"pathbbox" => {
self.spend(self.gs.path.elements().len())?;
let r = kurbo::Shape::bounding_box(&self.gs.path);
let inv = self.gs.ctm.inverse();
let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1));
@@ -1366,13 +1433,14 @@ impl Interp {
"stroke" => self.paint(None, true),
"clip" | "eoclip" => {
let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd };
self.spend(self.gs.path.elements().len())?;
let p = self.gs.path.clone();
if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) {
self.b.clip(p, rule);
}
}
"rectfill" | "rectstroke" | "rectclip" => {
let paths = self.rect_path();
let paths = self.rect_path()?;
if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) {
self.pop();
}
@@ -1607,9 +1675,9 @@ impl Interp {
/// A string's glyph outlines (device space) from the current point, with an extra
/// displacement `extra(index, code)` (user space) after each glyph; moves the current
/// point.
fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> {
let d = self.gs.font.clone()?;
let (font, m) = self.font_of(&d)?;
fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<Option<(BezPath, String)>, Flow> {
let Some(d) = self.gs.font.clone() else { return Ok(None) };
let Some((font, m)) = self.font_of(&d) else { return Ok(None) };
let mut p = self.current_user();
let mut path = BezPath::new();
let mut text = String::new();
@@ -1624,8 +1692,8 @@ impl Interp {
let (ex, ey) = extra(i, code);
p += adv + kurbo::Vec2::new(ex, ey);
}
self.move_to(p);
Some((path, text))
self.move_to(p)?;
Ok(Some((path, text)))
}
fn fill_text(&mut self, path: BezPath, text: &str) {
@@ -1641,11 +1709,12 @@ impl Interp {
}
}
fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) {
match self.text_path(s, extra) {
fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<(), Flow> {
match self.text_path(s, extra)? {
Some((path, text)) => self.fill_text(path, &text),
None => self.b.skip("text (no font)"),
}
Ok(())
}
/// Text and font operators (PostScript Language Reference §5.1, §8.2).
@@ -1703,20 +1772,20 @@ impl Interp {
"StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)),
"show" => {
let s = self.pop_string();
self.show_with(&s, |_, _| (0.0, 0.0));
self.show_with(&s, |_, _| (0.0, 0.0))?;
}
"ashow" => {
let s = self.pop_string();
let ay = self.popn();
let ax = self.popn();
self.show_with(&s, |_, _| (ax, ay));
self.show_with(&s, |_, _| (ax, ay))?;
}
"widthshow" => {
let s = self.pop_string();
let ch = self.popn() as u32;
let cy = self.popn();
let cx = self.popn();
self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) });
self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) })?;
}
"awidthshow" => {
let s = self.pop_string();
@@ -1725,18 +1794,18 @@ impl Interp {
let ch = self.popn() as u32;
let cy = self.popn();
let cx = self.popn();
self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) });
self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) })?;
}
"kshow" | "cshow" => {
// The procedure between characters is not run.
let s = self.pop_string();
self.pop();
self.show_with(&s, |_, _| (0.0, 0.0));
self.show_with(&s, |_, _| (0.0, 0.0))?;
}
"xshow" | "yshow" | "xyshow" => {
let disp: Vec<f64> = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect();
let s = self.pop_string();
self.explicit_show(&s, &disp, n);
self.explicit_show(&s, &disp, n)?;
}
"glyphshow" => {
let name = self.pop().key();
@@ -1747,7 +1816,7 @@ impl Interp {
let tmp = transformed(&d, Affine::IDENTITY);
tmp.borrow_mut().insert("Encoding".into(), array(e));
self.gs.font = Some(tmp);
self.show_with(b"A", |_, _| (0.0, 0.0));
self.show_with(b"A", |_, _| (0.0, 0.0))?;
self.gs.font = Some(d);
}
"charpath" => {
@@ -1755,14 +1824,17 @@ impl Interp {
let s = self.pop_string();
let saved = std::mem::take(&mut self.gs.path);
let start = self.gs.start;
match self.text_path(&s, |_, _| (0.0, 0.0)) {
match self.text_path(&s, |_, _| (0.0, 0.0))? {
Some((glyphs, _)) => {
let mut p = saved;
p.extend(glyphs);
// The outlines join the path: each element counts against the budget and
// the path limit.
self.gs.path = saved;
self.room(glyphs.elements().len() + 1)?;
self.spend(glyphs.elements().len())?;
self.gs.path.extend(glyphs);
if let Some(c) = self.gs.cur {
p.move_to(c);
self.gs.path.move_to(c);
}
self.gs.path = p;
self.gs.start = start;
}
None => self.gs.path = saved,
@@ -1812,7 +1884,7 @@ impl Interp {
/// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the
/// advances.
fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) {
fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) -> Result<(), Flow> {
let step = if n == "xyshow" { 2 } else { 1 };
let d: Vec<(f64, f64)> = (0..s.len())
.map(|k| {
@@ -1827,13 +1899,13 @@ impl Interp {
.collect();
let Some(fd) = self.gs.font.clone() else {
self.b.skip("text (no font)");
return;
return Ok(());
};
let Some((font, m)) = self.font_of(&fd) else { return };
let Some((font, m)) = self.font_of(&fd) else { return Ok(()) };
self.show_with(s, move |k, c| {
let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO;
let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0));
(dx - adv.x, dy - adv.y)
});
})
}
}
+94
View File
@@ -396,6 +396,100 @@ fn arcs_with_huge_angles_end_at_their_second_angle() {
assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50));
}
/// Parses `body` after a square through [`eps_soon`]: the square stays, and the program stops at
/// the operation limit.
fn runs_out_of_budget(body: &str) {
let doc = eps_soon(&format!("0 0 10 10 rectfill {body}"));
assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped);
assert_eq!(doc.skipped, ["PostScript (operation limit)"], "{body}");
}
#[test]
fn paths_made_in_a_loop_run_out_of_the_budget() {
// Each curve, rectangle element and glyph outline element an operator makes counts against the
// operation budget: `arc` at a radius of 2.2 × 10^18 makes about 6,800 curves a call,
// `rectclip` with an array of 2,000 rectangles makes 10,000 elements, and `charpath` makes the
// outlines of 1,000 H's.
for body in [
"{ newpath 0 0 2200000000000000000 0 1440 arc } loop",
"/a 8000 array def 0 1 7999 { a exch 0 put } for { gsave a rectclip grestore } loop",
"/Helvetica findfont 10 scalefont setfont /s 1000 string def 0 1 999 { s exch 72 put } for \
{ newpath 0 0 moveto s false charpath } loop",
] {
runs_out_of_budget(body);
}
}
#[test]
fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() {
// `pathbbox` reads each element of the current path, and `gsave`, `save` and `clip` copy it:
// each element counts against the operation budget. The path has 100,001 elements.
let path = "newpath 0 0 moveto 0 1 99999 { pop 1 0 rlineto } for";
for body in ["{ pathbbox pop pop pop pop } loop", "{ gsave grestore } loop", "{ save restore } loop", "{ gsave clip grestore } loop"] {
runs_out_of_budget(&format!("{path} {body}"));
}
}
#[test]
fn nested_loops_run_out_of_the_budget() {
// Each pass of `loop`, `repeat`, `for` and `forall` counts against the operation budget, and
// so does each item of a procedure that is not a name. `clear` empties the operand stack that
// the passes fill.
let literals = format!("{{ {} clear }} loop", "0 ".repeat(100_000));
for body in [
"{ {} loop } loop",
"{ 1000000 {} repeat } loop",
"{ 0 1 999999 {} for clear } loop",
"/s 1000000 string def { s {} forall clear } loop",
"/a 1000000 array def { a {} forall clear } loop",
"/d 1 dict def 0 1 99999 { d exch 0 put } for { d {} forall clear } loop",
literals.as_str(),
] {
runs_out_of_budget(body);
}
}
#[test]
fn paths_stop_at_the_path_limit() {
// A path holds at most 1,048,576 elements. An operator that would add more stops the program:
// the square drawn before it stays, and the square after it is left out.
for body in [
"newpath 0 0 moveto 1100 { 1000 { 1 0 rlineto } repeat } repeat",
"newpath 0 0 moveto 0 0 0 0 0 0 1100 { 1000 { 6 copy rcurveto } repeat } repeat",
"newpath 0 0 moveto 1100 { 1000 { closepath } repeat } repeat",
// 1,024 turns at a radius of 2 × 10^17: about 1,171,000 curves.
"newpath 0 0 200000000000000000 0 368640 arc",
// At a radius of 10^300, kurbo divides the arc into more curves than a usize can count.
"newpath 0 0 10 300 exp 0 90 arc",
// The outlines of 100,000 H's, 13 elements each.
"/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \
0 0 moveto s false charpath",
// 210,000 rectangles of five elements each.
"/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill",
] {
let doc = eps_soon(&format!("0 0 10 10 rectfill {body} 20 20 10 10 rectfill"));
assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped);
assert_eq!(doc.skipped, ["PostScript (path limit)"], "{body}");
}
// A `moveto` replaces a `moveto` just before it (PostScript Language Reference §8.2, `moveto`),
// and so does the current point that `show` sets, so 550,000 pieces of text placed with
// `moveto` and `show` leave a path of one element.
let doc = eps_soon("/Helvetica findfont 10 scalefont setfont 550 { 1000 { 0 0 moveto ( ) show } repeat } repeat 0 0 10 10 rectfill");
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
assert_eq!(doc.flatten().len(), 1);
}
#[test]
fn pdf_paths_stop_at_the_path_limit() {
// A content stream's current path holds at most 1,048,576 elements: `re` (five elements each)
// and `h` stop the stream at the limit, and the square painted before it stays.
for (op, n) in [("0 0 1 1 re ", 210_000), ("h ", 1_100_000)] {
let doc = parse(&page_pdf(&format!("0 0 10 10 re f 0 0 m {} f 20 20 10 10 re f", op.repeat(n)), "", vec![])).unwrap();
assert_eq!(doc.flatten().len(), 1, "{op}: {:?}", doc.skipped);
assert_eq!(doc.skipped, ["content (path limit)"], "{op}");
}
}
#[test]
fn closing_an_empty_path_is_a_no_op() {
// "s", "b" and "b*" closed the path without a current point (kurbo asserts on that).
+14 -2
View File
@@ -49,8 +49,9 @@ pub fn saved_by(text: &str) -> Option<String> {
serde_json::from_str::<Meta>(text).ok()?.saved_by
}
/// The compositions that were open when a project file was saved (`activeComp` and `openComps`
/// next to `savedBy`), so it reopens where it was left (#748). Editor state rather than project
/// The compositions that were open when a project file was saved and their current times
/// (`activeComp`, `openComps` and `compTimes` next to `savedBy`), so it reopens where it was left
/// (#748, #1714). Editor state rather than project
/// content: opening a comp doesn't make the project unsaved, and it isn't part of
/// [`Project`]. Older files have none; older versions ignore it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
@@ -62,6 +63,10 @@ pub struct SavedView {
/// The open comps (Timeline tabs), in tab order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub open_comps: Vec<ItemId>,
/// Each comp's current time, so a project reopens on the frame it was saved at rather than
/// at 0, where its layers may not have started yet (#1714).
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub comp_times: BTreeMap<ItemId, Tick>,
}
/// The view a project file was saved with ([`SavedView`]); empty for older files. Values that
@@ -73,6 +78,8 @@ pub fn saved_view(text: &str) -> SavedView {
active_comp: Option<serde_json::Value>,
#[serde(rename = "openComps", default)]
open_comps: Option<serde_json::Value>,
#[serde(rename = "compTimes", default)]
comp_times: Option<serde_json::Value>,
}
let Ok(m) = serde_json::from_str::<Meta>(text) else { return SavedView::default() };
SavedView {
@@ -82,6 +89,11 @@ pub fn saved_view(text: &str) -> SavedView {
.as_ref()
.and_then(serde_json::Value::as_array)
.map_or_else(Vec::new, |a| a.iter().filter_map(serde_json::Value::as_u64).map(ItemId).collect()),
comp_times: m
.comp_times
.as_ref()
.and_then(serde_json::Value::as_object)
.map_or_else(BTreeMap::new, |o| o.iter().filter_map(|(k, v)| Some((ItemId(k.parse().ok()?), Tick(v.as_i64()?)))).collect()),
}
}
+8 -2
View File
@@ -173,14 +173,20 @@ fn project_files_name_their_version_and_always_read_back() {
#[test]
fn project_files_remember_the_open_comps() {
let (p, cid) = project_with_layer();
let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid] };
let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() };
let text = p.to_file_json_with_view(&view).unwrap();
assert_eq!(crate::saved_view(&text), view);
assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project");
// Files from before #748 have no view; a garbled one is ignored, not an error.
assert_eq!(crate::saved_view(&p.to_file_json().unwrap()), crate::SavedView::default());
let odd = text.replacen(&format!("\"activeComp\": {}", cid.0), "\"activeComp\": \"Main\"", 1);
assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, open_comps: vec![cid] });
assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, ..view.clone() });
// Entries that aren't an id and a whole tick count are skipped; the rest are kept.
let times = text.replacen("\"compTimes\": {", "\"compTimes\": {\"x\": 1, \"7\": \"a\", \"8\": 1.5, \"9\": -4,", 1);
let expect = [(cid, effectcraft_time::Tick(4_237_833_600)), (crate::ItemId(9), effectcraft_time::Tick(-4))].into();
assert_eq!(crate::saved_view(&times).comp_times, expect);
let not_a_map = text.replacen("\"compTimes\": {", "\"compTimes\": [], \"unused\": {", 1);
assert_eq!(crate::saved_view(&not_a_map), crate::SavedView { comp_times: Default::default(), ..view });
}
/// The Project panel's "used N times": layers in every comp that use the item directly, a precomp
+18 -2
View File
@@ -538,7 +538,9 @@ pub struct RenderOpts {
/// Output scale relative to comp pixels (1 = Full, 0.5 = Half…).
pub scale: f64,
pub motion_blur: bool,
/// Render guide layers (viewer only).
/// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested
/// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in
/// After Effects (see [`Renderer::nested_opts`]).
pub guides: bool,
/// Draft quality: fewer motion-blur samples, bilinear only.
pub draft: bool,
@@ -742,9 +744,16 @@ impl<'a> Renderer<'a> {
sub
}
/// The render options one level down (precomps, collapsed precomps, comps read by effects):
/// guide layers render only in their own comp, never when it is nested.
fn nested_opts(&self) -> RenderOpts {
RenderOpts { guides: false, ..self.opts }
}
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
fn nested(&self) -> Renderer<'a> {
Renderer {
opts: self.nested_opts(),
depth: self.depth + 1,
outer: None,
opacity_mul: 1.0,
@@ -835,7 +844,10 @@ impl<'a> Renderer<'a> {
if let Some(slot) = slot {
*slot = Some(link);
}
Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx))
Some((
Renderer { opts: self.nested_opts(), depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self },
nctx,
))
}
pub(crate) fn opacity_mul(&self) -> f32 {
@@ -2402,8 +2414,12 @@ mod tests_footage_size;
#[cfg(test)]
mod tests_frame_blend;
#[cfg(test)]
mod tests_guides;
#[cfg(test)]
mod tests_mask_blur;
#[cfg(test)]
mod tests_mask_expansion;
#[cfg(test)]
mod tests_nested;
#[cfg(test)]
mod tests_paint;
+7 -13
View File
@@ -3,7 +3,7 @@
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::Value;
use effectcraft_path::{FillRule, StrokeStyle};
use effectcraft_path::{FillRule, Join};
use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur};
use effectcraft_raster::{Mask, box_blur, gaussian_blur};
use rayon::prelude::*;
@@ -72,21 +72,15 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup,
let variable = !sp.feather.is_empty();
let mut cov = if variable {
effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear)
} else if exp.abs() > 0.01 {
// Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right
// when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a
// ring around an uncovered middle (growing) or islands (shrinking).
let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0);
effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero)
} else {
effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero)
};
if exp.abs() > 0.01 && !variable {
let ring = effectcraft_path::stroke_coverage(
std::slice::from_ref(&path),
&StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() },
&m,
w,
h,
);
cov.data.par_iter_mut().zip(ring.data.par_iter()).for_each(|(c, r)| {
*c = if exp > 0.0 { (*c + r - *c * r).min(1.0) } else { (*c * (1.0 - r)).max(0.0) };
});
}
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
if feather[0] > 0.0 || feather[1] > 0.0 {
let img = cov.to_image();
+11 -2
View File
@@ -57,6 +57,10 @@ struct Draw {
blend: BlendMode,
/// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D).
surfaces: SurfaceColors,
/// Paint item of the contents group this draw was collected in (as opposed to one bubbled up
/// from a nested group). Merge Paths drops only these paints above it (#1174); a group's own
/// fill or stroke keeps rendering inside the group.
local: bool,
}
/// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`]
@@ -234,6 +238,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
local: true,
});
}
"stroke" => {
@@ -247,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
local: true,
});
}
"gfill" | "gstroke" => {
@@ -270,6 +276,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
local: true,
});
}
"trim" => {
@@ -328,8 +335,9 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
arena.push(ops::merge(&input, mode));
live = vec![arena.len() - 1];
// As in After Effects, fills and strokes above a Merge Paths in its group are not
// rendered: the merged path is painted by the paint items below it.
draws.clear();
// rendered: the merged path is painted by the paint items below it. Paint inside
// groups above the merge still renders — it belongs to those groups (#1174).
draws.retain(|d| !d.local);
}
"group" => {
let Some(inner) = g.sub("contents") else { continue };
@@ -341,6 +349,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
for mut d in cd {
d.xf = m * d.xf;
d.opacity *= op;
d.local = false;
draws.push(d);
}
live.extend(cl);
+18
View File
@@ -76,6 +76,24 @@ fn merge_add_feeds_fill_below_and_drops_paint_above() {
assert!((coverage(&img) - 80.0 * 40.0).abs() < 20.0, "{}", coverage(&img));
}
/// #1174: Adding Merge Paths to a shape layer's Contents, after the group that holds the
/// shape and its paint, erased the render. The merge drops paints above it *in its group* —
/// not paint that belongs to a group above it.
#[test]
fn merge_paths_below_a_group_keeps_the_group_paint() {
for mode in [0u32, 1] {
let (p, cid) = shape_comp(|ids| {
let rect = build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(ids, RED);
vec![build::shape_group(ids, "Group 1", vec![rect, fill]), op(ids, "merge", &[("mode", Value::Enum(mode))])]
});
let img = frame(&p, cid);
let c = img.get(100, 50);
assert!(c[0] > 0.99 && c[1] < 0.01 && c[3] > 0.99, "mode {mode}: group rectangle must still render, got {c:?}");
assert!((coverage(&img) - 40.0 * 40.0).abs() < 20.0, "mode {mode}: {}", coverage(&img));
}
}
#[test]
fn merge_subtract_and_intersect() {
let sub = |mode: u32| {
+108
View File
@@ -0,0 +1,108 @@
//! Guide layers: drawn in their own comp's viewer (`RenderOpts::guides`), never when that comp is
//! nested in another (precomp, collapsed precomp, 2D or 3D), and never in final renders.
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build;
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer};
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), three_d: bool) -> Layer {
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
l.switches.three_d = three_d;
l
}
#[derive(Clone, Copy, Debug)]
enum Nest {
/// A plain precomp layer (the nested comp rendered on its own, then composited).
Precomp,
/// Collapse Transformations on a 2D precomp layer.
Collapsed,
/// A collapsed 3D precomp in a Classic 3D comp.
Collapsed3d,
/// A collapsed 3D precomp in an Advanced 3D comp.
CollapsedAdvanced,
}
/// "Inner": a red 100×100 background with a green 40×40 guide layer in the middle; "Outer": the
/// same size, holding only Inner nested as `nest`.
fn scene(nest: Nest) -> (Project, ItemId, ItemId) {
let three_d = matches!(nest, Nest::Collapsed3d | Nest::CollapsedAdvanced);
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut guide = solid(&mut p, &inner, [0.0, 1.0, 0.0], (40, 40), three_d);
guide.switches.guide = true;
let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d);
let mut inner = inner;
inner.layers = vec![guide, bg];
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
if matches!(nest, Nest::CollapsedAdvanced) {
outer.renderer = effectcraft_project::Renderer::Advanced3D;
}
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
pre.switches.collapse = !matches!(nest, Nest::Precomp);
pre.switches.three_d = three_d;
outer.layers = vec![pre];
let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into()));
(p, iid, oid)
}
/// The centre pixel of `comp` rendered with or without guide layers.
fn centre(p: &Project, comp: ItemId, guides: bool) -> [f32; 4] {
Renderer::new(p, &NoFootage, RenderOpts { guides, ..Default::default() }).comp_frame(comp, Tick::ZERO).get(50, 50)
}
fn is_green(c: [f32; 4]) -> bool {
c[1] > 0.9 && c[0] < 0.1
}
fn is_red(c: [f32; 4]) -> bool {
c[0] > 0.9 && c[1] < 0.1
}
#[test]
fn guide_layer_renders_in_its_own_comp_with_guides_on() {
let (p, inner, _) = scene(Nest::Precomp);
let c = centre(&p, inner, true);
assert!(is_green(c), "guide layer drawn in its own comp's viewer: {c:?}");
}
#[test]
fn final_renders_leave_guide_layers_out_by_default() {
assert!(!RenderOpts::default().guides);
let (p, inner, _) = scene(Nest::Precomp);
let c = centre(&p, inner, false);
assert!(is_red(c), "guide layer left out of final renders: {c:?}");
}
#[test]
fn guide_layers_of_a_nested_comp_never_render_in_the_parent() {
for nest in [Nest::Precomp, Nest::Collapsed, Nest::Collapsed3d, Nest::CollapsedAdvanced] {
let (p, inner, outer) = scene(nest);
// The nested comp's own viewer still shows them…
let own = centre(&p, inner, true);
assert!(is_green(own), "{nest:?}: guide layer in its own comp: {own:?}");
// …but not the parent's viewer (guides on) nor its final render.
for guides in [true, false] {
let c = centre(&p, outer, guides);
assert!(is_red(c), "{nest:?}, guides {guides}: nested guide layer drawn in the parent: {c:?}");
}
}
}
#[test]
fn a_guide_precomp_layer_of_the_top_comp_follows_the_guides_option() {
// The precomp layer itself is a guide layer in the top comp: shown in the viewer, not in
// final renders.
let (mut p, _, outer) = scene(Nest::Precomp);
p.comp_mut(outer).unwrap().layers[0].switches.guide = true;
assert!(is_red(centre(&p, outer, true)));
assert!(centre(&p, outer, false)[3] < 0.01, "nothing drawn without guides");
}
+82
View File
@@ -0,0 +1,82 @@
//! Mask Expansion grows or shrinks the mask outline by its distance, however large: past the
//! shape's curvature radius it must still cover everything within the distance of the path,
//! never leave a ring around an uncovered middle.
use effectcraft_color::Label;
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Node, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer};
const W: u32 = 400;
const H: u32 = 300;
/// A white 400×300 solid with one mask of `shape` in `mode` expanded by `expansion` pixels.
fn project(shape: ShapePath, mode: MaskMode, expansion: f64) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: W, height: H, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (W, H), None);
let mut next = p.next_id;
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", shape, mode, [255, 0, 0]);
p.next_id = next;
m.get_mut("expansion").unwrap().value = Value::Scalar(expansion);
l.props.sub_mut("masks").unwrap().children.push(Node::Group(m));
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
/// Alpha at each of `points` for a Subtract mask on a 40 px circle at the centre (the hole).
fn hole(expansion: f64, points: &[(i64, i64)]) -> Vec<f32> {
let (p, cid) = project(ShapePath::ellipse([200.0, 150.0], 40.0, 40.0), MaskMode::Subtract, expansion);
let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
points.iter().map(|&(x, y)| img.get(x, y)[3]).collect()
}
#[test]
fn no_expansion_cuts_the_circle() {
let a = hole(0.0, &[(200, 150), (215, 150), (225, 150)]);
assert!(a[0] < 0.01 && a[1] < 0.01, "inside the 20 px radius is cut: {a:?}");
assert!(a[2] > 0.99, "outside it is kept: {a:?}");
}
#[test]
fn expansion_past_the_radius_grows_a_solid_hole() {
// +60 on a 20 px radius: a solid hole of radius 80. The fill + stroke blend left a ring
// from 40 to 80 px with the layer showing at 21–39 px.
let a = hole(60.0, &[(200, 150), (230, 150), (200, 120), (275, 150), (285, 150)]);
assert!(a[..4].iter().all(|&v| v < 0.01), "everything within 80 px is cut: {a:?}");
assert!(a[4] > 0.99, "beyond 80 px is kept: {a:?}");
}
#[test]
fn huge_expansion_cuts_the_whole_frame() {
// +600 reaches every pixel; the blend left only the 20 px circle cut.
let a = hole(600.0, &[(200, 150), (230, 150), (0, 0), (399, 299), (399, 0)]);
assert!(a.iter().all(|&v| v < 0.01), "{a:?}");
}
#[test]
fn negative_expansion_shrinks_then_removes_the_hole() {
let a = hole(-15.0, &[(200, 150), (210, 150)]);
assert!(a[0] < 0.01, "a 5 px hole remains: {a:?}");
assert!(a[1] > 0.99, "shrunk away from 10 px out: {a:?}");
let a = hole(-25.0, &[(200, 150), (190, 150)]);
assert!(a.iter().all(|&v| v > 0.99), "shrunk past its radius, nothing is cut: {a:?}");
}
#[test]
fn expanded_rect_add_mask_has_round_corners() {
// A 100 px square mask (Add) grown by 30: the sides move out 30 px, the corners round off.
let (p, cid) = project(ShapePath::rect([200.0, 150.0], 100.0, 100.0), MaskMode::Add, 30.0);
let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
assert!(img.get(275, 150)[3] > 0.99, "25 px past the right side is inside");
assert!(img.get(285, 150)[3] < 0.01, "35 px past it is outside");
// 25 px diagonally out from the corner (250,100) is within 30 px: inside.
assert!(img.get(267, 82)[3] > 0.99);
// The square's bounding corner (280,70) is 42 px from the corner: a miter would cover it.
assert!(img.get(277, 73)[3] < 0.01);
}

Some files were not shown because too many files have changed in this diff Show More